v0.1 wave 1 (steps 2+3+4): SQLite ledger + FastAPI skeleton + async job runner
- 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
This commit is contained in:
parent
4e668a79e1
commit
0ec3a04676
20 changed files with 3328 additions and 0 deletions
235
tests/test_projects_api.py
Normal file
235
tests/test_projects_api.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""HTTP-level tests for the /projects surface."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import sample_project_payload
|
||||
|
||||
|
||||
def test_register_project_returns_owner(client):
|
||||
tc, ctx = client
|
||||
r = tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="proj-alpha"),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["project"]["name"] == "proj-alpha"
|
||||
|
||||
|
||||
def test_register_requires_auth(client):
|
||||
tc, _ = client
|
||||
r = tc.post("/projects", json=sample_project_payload())
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_register_rejects_unknown_token(client):
|
||||
tc, _ = client
|
||||
r = tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": "Bearer ct_definitely_not_real"},
|
||||
json=sample_project_payload(),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_list_projects_filters_by_token(client):
|
||||
tc, ctx = client
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="alpha-1"),
|
||||
)
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
||||
json=sample_project_payload(name="bravo-1"),
|
||||
)
|
||||
r_a = tc.get("/projects", headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"})
|
||||
assert r_a.status_code == 200
|
||||
names = {p["name"] for p in r_a.json()["projects"]}
|
||||
assert names == {"alpha-1"}
|
||||
|
||||
# Admin sees both
|
||||
r_admin = tc.get("/projects", headers={"Authorization": f"Bearer {ctx['admin_bearer']}"})
|
||||
admin_names = {p["name"] for p in r_admin.json()["projects"]}
|
||||
assert admin_names == {"alpha-1", "bravo-1"}
|
||||
|
||||
|
||||
def test_get_project_404_for_other_token(client):
|
||||
"""Existence-leak guard: bravo querying alpha's project gets 404 (not 403)."""
|
||||
tc, ctx = client
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="secret-proj"),
|
||||
)
|
||||
r = tc.get(
|
||||
"/projects/secret-proj",
|
||||
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_get_project_own(client):
|
||||
tc, ctx = client
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="own-proj"),
|
||||
)
|
||||
r = tc.get(
|
||||
"/projects/own-proj",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["project"]["name"] == "own-proj"
|
||||
|
||||
|
||||
def test_update_project_owner_can(client):
|
||||
tc, ctx = client
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="upd-proj"),
|
||||
)
|
||||
payload = sample_project_payload(name="upd-proj")
|
||||
payload["git_url"] = "https://changed.example/repo.git"
|
||||
r = tc.put(
|
||||
"/projects/upd-proj",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=payload,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["project"]["git_url"] == "https://changed.example/repo.git"
|
||||
|
||||
|
||||
def test_update_project_other_token_404(client):
|
||||
tc, ctx = client
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="no-touch"),
|
||||
)
|
||||
payload = sample_project_payload(name="no-touch")
|
||||
payload["git_url"] = "https://hijack.example/x.git"
|
||||
r = tc.put(
|
||||
"/projects/no-touch",
|
||||
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
||||
json=payload,
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_delete_project_owner_can(client):
|
||||
tc, ctx = client
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="del-proj"),
|
||||
)
|
||||
r = tc.delete(
|
||||
"/projects/del-proj",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Confirm gone
|
||||
r2 = tc.get(
|
||||
"/projects/del-proj",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
)
|
||||
assert r2.status_code == 404
|
||||
|
||||
|
||||
def test_delete_project_other_token_404(client):
|
||||
tc, ctx = client
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="hands-off"),
|
||||
)
|
||||
r = tc.delete(
|
||||
"/projects/hands-off",
|
||||
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_admin_can_modify_any_project(client):
|
||||
tc, ctx = client
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="adminable"),
|
||||
)
|
||||
payload = sample_project_payload(name="adminable")
|
||||
payload["git_url"] = "https://admin-edit.example/x.git"
|
||||
r = tc.put(
|
||||
"/projects/adminable",
|
||||
headers={"Authorization": f"Bearer {ctx['admin_bearer']}"},
|
||||
json=payload,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["project"]["git_url"] == "https://admin-edit.example/x.git"
|
||||
|
||||
|
||||
def test_register_duplicate_409_for_owner(client):
|
||||
tc, ctx = client
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="dup"),
|
||||
)
|
||||
r = tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="dup"),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_register_duplicate_404_for_other(client):
|
||||
"""Other-token re-registering an existing name gets 404 (existence leak guard)."""
|
||||
tc, ctx = client
|
||||
tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"},
|
||||
json=sample_project_payload(name="hidden"),
|
||||
)
|
||||
r = tc.post(
|
||||
"/projects",
|
||||
headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"},
|
||||
json=sample_project_payload(name="hidden"),
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_admin_token_endpoint_admin_only(client):
|
||||
tc, ctx = client
|
||||
r = tc.get("/admin/tokens", headers={"Authorization": f"Bearer {ctx['alpha_bearer']}"})
|
||||
assert r.status_code == 403
|
||||
r2 = tc.get("/admin/tokens", headers={"Authorization": f"Bearer {ctx['admin_bearer']}"})
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
def test_admin_revoke_token(client):
|
||||
tc, ctx = client
|
||||
r = tc.delete(
|
||||
f"/admin/tokens/{ctx['bravo_name']}",
|
||||
headers={"Authorization": f"Bearer {ctx['admin_bearer']}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Bravo's token should now fail
|
||||
r2 = tc.get("/projects", headers={"Authorization": f"Bearer {ctx['bravo_bearer']}"})
|
||||
assert r2.status_code == 403
|
||||
|
||||
|
||||
def test_admin_cannot_revoke_admin(client):
|
||||
tc, ctx = client
|
||||
r = tc.delete(
|
||||
"/admin/tokens/admin",
|
||||
headers={"Authorization": f"Bearer {ctx['admin_bearer']}"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
Loading…
Add table
Add a link
Reference in a new issue