crafting-table/tests/conftest.py
Kayos 0ec3a04676 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
2026-04-29 08:17:41 -07:00

159 lines
5.2 KiB
Python

"""Shared pytest fixtures for crafting-table tests.
Every test gets:
- A fresh tmp dir for db / log_dir / workspace_root / admin_bearer file
- Module reload so server.py's module-level singletons pick up our env
- IP allowlist patched to accept 127.0.0.1 (TestClient reports 'testclient')
- An admin bearer + a pre-minted app token + a second app token for isolation tests
Tests can then use `client` (TestClient + ctx dict) for HTTP-level tests, or
`db_only` if they only need a DB instance (no server boot).
"""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
import pytest
# ---------- per-test workspace --------------------------------------------
@pytest.fixture
def tmp_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
db_path = tmp_path / "crafting.db"
log_dir = tmp_path / "jobs"
workspace_root = tmp_path / "workspace"
admin_bearer = tmp_path / "admin-bearer.txt"
log_dir.mkdir()
workspace_root.mkdir()
monkeypatch.setenv("CRAFTING_DB", str(db_path))
monkeypatch.setenv("CRAFTING_LOG_DIR", str(log_dir))
monkeypatch.setenv("CRAFTING_WORKSPACE", str(workspace_root))
monkeypatch.setenv("CRAFTING_ADMIN_BEARER", str(admin_bearer))
monkeypatch.setenv("CRAFTING_MAX_CONCURRENT", "2")
monkeypatch.setenv("CRAFTING_DEFAULT_JOB_TIMEOUT", "10")
monkeypatch.setenv("CRAFTING_LAN_CIDRS", "127.0.0.0/8,::1/128,10.0.0.0/8")
monkeypatch.setenv("CRAFTING_GC_INTERVAL", "9999")
monkeypatch.setenv("CRAFTING_GC_AGE", "86400")
yield {
"db_path": db_path,
"log_dir": log_dir,
"workspace_root": workspace_root,
"admin_bearer": admin_bearer,
}
@pytest.fixture
def db_only(tmp_workspace):
"""For tests that don't need the server — just a DB instance."""
# Import locally so env vars are in effect before module import.
from crafting_table.db import DB
return DB(str(tmp_workspace["db_path"]))
# ---------- server reload + TestClient ------------------------------------
def _reload_server_modules():
"""Drop cached modules so module-level singletons re-bind with current env."""
for name in [
"crafting_table.server",
"crafting_table.runner",
"crafting_table.workspace",
"crafting_table.auth",
"crafting_table.db",
"crafting_table.config",
"crafting_table.models",
"crafting_table",
]:
sys.modules.pop(name, None)
@pytest.fixture
def server(tmp_workspace, monkeypatch: pytest.MonkeyPatch):
"""Reload server module + return the module so tests can poke internals."""
_reload_server_modules()
from crafting_table import auth as auth_mod # noqa: WPS433
monkeypatch.setattr(auth_mod, "_client_ip", lambda _req: "127.0.0.1")
from crafting_table import server as srv # noqa: WPS433
return srv
@pytest.fixture
def client(server, tmp_workspace):
"""FastAPI TestClient + ctx dict (admin_bearer, app_token, other_token, ...).
The TestClient context-manages lifespan, so on entry the runner starts +
admin token is bootstrapped. On exit the runner stops cleanly.
"""
from fastapi.testclient import TestClient
with TestClient(server.app) as tc:
admin_bearer = tmp_workspace["admin_bearer"].read_text().strip()
# Mint two app tokens for isolation tests
r = tc.post(
"/admin/tokens",
headers={"Authorization": f"Bearer {admin_bearer}"},
json={"name": "alpha", "is_admin": False, "ip_cidrs": []},
)
assert r.status_code == 200, r.text
alpha_bearer = r.json()["bearer"]
r2 = tc.post(
"/admin/tokens",
headers={"Authorization": f"Bearer {admin_bearer}"},
json={"name": "bravo", "is_admin": False, "ip_cidrs": []},
)
assert r2.status_code == 200, r2.text
bravo_bearer = r2.json()["bearer"]
yield tc, {
"admin_bearer": admin_bearer,
"alpha_bearer": alpha_bearer,
"alpha_name": "alpha",
"bravo_bearer": bravo_bearer,
"bravo_name": "bravo",
"server": server,
}
# ---------- shared sample data ---------------------------------------------
def sample_project_payload(*, name: str = "demo", recipe_cmds: dict | None = None) -> dict:
cmds = recipe_cmds or {
"build": "echo build && true",
"test": "echo test && true",
"lint": "echo lint && true",
"audit": "echo audit && true",
}
return {
"name": name,
"git_url": "/dev/null", # tests don't actually clone unless they want to
"default_branch": "main",
"languages": ["python"],
"subprojects": [
{
"path": ".",
"language": "python",
"build": cmds.get("build"),
"test": cmds.get("test"),
"lint": cmds.get("lint"),
"audit": cmds.get("audit"),
"timeout_secs": 5,
}
],
"schedule": {"audit": "manual", "test": None, "build": None, "lint": None},
"notify": {"email": [], "on": [], "auto_patch": False},
"created_at": 0,
"updated_at": 0,
}