""" HTTP-level integration tests for the agentpaas FastAPI surface. audit/AUDIT_2026-06-05.md critical #6 baseline: $ grep -rln "TestClient\|httpx" tests/ (no matches) 3,477 LOC of route handlers had ZERO route-level coverage before this file existed. A `tenant_id` filter typo could ship to production and CI would still be green. This file is the bootstrap skeleton — it covers the *most expensive* failure modes (auth bypass, cross-tenant read, loopback guard) so the framework is in place; subsequent PRs should fan out per-route happy + sad paths from here. Conventions for new tests: - Use the `client` fixture (TestClient bound to an isolated in-memory SQLite DB). - Use `tenant_and_key` for an authenticated request. - For cross-tenant tests, build the second tenant inline via the `db` fixture exposed alongside. - Assertions favor *what must not happen* over *exact response shape*; we are testing security boundaries, not API ergonomics. """ from __future__ import annotations # IMPORTANT: the env var has to be set BEFORE agentpaas.config is imported, # because settings.database_url is read at module-import time. conftest.py # runs first (Python imports), and *this* module's top-level os.environ # call lands before the `from agentpaas...` lines below. import os os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:") import json import secrets import pytest try: from fastapi.testclient import TestClient except ImportError: # httpx not installed — skip entire file gracefully pytest.skip( "fastapi.testclient requires httpx — `pip install -e ./agentpaas/[dev]`", allow_module_level=True, ) from agentpaas.api.app import app from agentpaas.api.middleware.auth import hash_key from agentpaas.db.models import Database, gen_id, now_utc import agentpaas.db.session as _session_mod # ───────────────────────────────────────────────────────────────────────────── # Fixtures # ───────────────────────────────────────────────────────────────────────────── @pytest.fixture def db(): """Fresh in-memory SQLite per test. Database.__init__ re-runs all migrations + the zombie-reaper migration; a brand-new `:memory:` DB is guaranteed clean. We swap the module-level singleton in `agentpaas.db.session` so both `Depends(get_database)` AND the direct `get_db()` calls inside `authenticate()` see the same DB.""" prev = _session_mod._db _session_mod._db = Database("sqlite:///:memory:") try: yield _session_mod._db finally: _session_mod._db = prev @pytest.fixture def client(db): """TestClient bound to the isolated DB. We don't override any dependencies — by patching `_session_mod._db`, every code path that calls `get_db()` (Depends, or direct) hits the test DB.""" with TestClient(app) as c: yield c @pytest.fixture def tenant_and_key(db): """Insert a tenant + a user + an active admin-scope API key. Returns (tenant_id, raw_api_key). The raw key is shown ONCE — same contract as production /bootstrap.""" tid = gen_id("tn_") uid = gen_id("usr_") key = f"ap_{secrets.token_hex(16)}" now = now_utc() db.execute( "INSERT INTO tenants (id, name, plan, status, created_at) " "VALUES (?, ?, 'free', 'active', ?)", (tid, "test-tenant", now), ) db.execute( "INSERT INTO users (id, tenant_id, email, role, created_at) " "VALUES (?, ?, '', 'admin', ?)", (uid, tid, now), ) db.execute( "INSERT INTO api_keys " "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, " " rate_limit, status, created_at) " "VALUES (?, ?, ?, ?, ?, 'test-key', ?, 600, 'active', ?)", ( gen_id("key_"), tid, uid, hash_key(key), key[:8], json.dumps(["agents:*", "keys:*", "admin:*"]), now, ), ) db.commit() return tid, key def _auth(key: str) -> dict: return {"Authorization": f"Bearer {key}"} # ───────────────────────────────────────────────────────────────────────────── # Health / no-auth surface # ───────────────────────────────────────────────────────────────────────────── def test_setup_health_no_auth(client): """SetupWizard polls /api/v1/setup/health before bootstrap — must be reachable without a key, and must not leak DB state.""" r = client.get("/api/v1/setup/health") assert r.status_code == 200 body = r.json() assert body.get("ok") is True # Should not include things like raw env vars / paths / tenant counts assert "secret" not in str(body).lower() assert "password" not in str(body).lower() # ───────────────────────────────────────────────────────────────────────────── # Auth dependency — critical surface, was untested # ───────────────────────────────────────────────────────────────────────────── def test_list_agents_requires_auth(client): """No Authorization header → 401. audit #14 root cause: handlers without `Depends(get_tenant)` slip through this gate silently; this test enforces it on /agents.""" r = client.get("/api/v1/agents") assert r.status_code == 401, ( f"Expected 401 for missing auth, got {r.status_code}: {r.text[:300]}" ) def test_list_agents_rejects_bad_key(client): """Garbage Bearer → 401, not 500. Catches both 'key absent in DB' and 'authenticate() raised unhandled' regressions.""" r = client.get("/api/v1/agents", headers=_auth("ap_doesnotexist_0000000000000000")) assert r.status_code == 401, ( f"Expected 401 for invalid key, got {r.status_code}: {r.text[:300]}" ) def test_list_agents_accepts_valid_key(client, tenant_and_key): """Happy path: real key → 200 + list payload. Anchors the authenticate() success path; without this, the legacy-hash auto- migration logic (audit SEC-01 leftover at api/middleware/auth.py around _hash_key_legacy) is structurally untested.""" _, key = tenant_and_key r = client.get("/api/v1/agents", headers=_auth(key)) assert r.status_code == 200, r.text[:300] body = r.json() # tolerant: response may be a bare list or {"items": [...]} or {"agents": [...]} items = body if isinstance(body, list) else ( body.get("items") or body.get("agents") or [] ) assert isinstance(items, list) # ───────────────────────────────────────────────────────────────────────────── # OntoRefactor governance vertical slice # ───────────────────────────────────────────────────────────────────────────── def test_governance_requires_auth(client): r = client.get("/api/v1/governance/projects") assert r.status_code == 401 def test_governance_bootstrap_and_agent_ingest(client, tenant_and_key): _, key = tenant_and_key seeded = client.post( "/api/v1/governance/bootstrap/tenant-isolation", headers=_auth(key) ) assert seeded.status_code == 200, seeded.text[:500] body = seeded.json() project_id = body["project"]["id"] assert body["validation"]["valid"] is True assert body["overview"]["layers"]["M3"] == 8 analysis = client.post( f"/api/v1/governance/projects/{project_id}/agent-runs/analyze", headers=_auth(key), json={ "source_type": "ddl", "source_name": "orders.sql", "environment": "development", "content": "CREATE TABLE dcp_order (id BIGINT PRIMARY KEY, amount DECIMAL(12,2));", }, ) assert analysis.status_code == 200, analysis.text[:500] assert analysis.json()["status"] == "completed" assert len(analysis.json()["trace"]) >= 5 graph = client.get( f"/api/v1/governance/projects/{project_id}/graph", headers=_auth(key) ) assert graph.status_code == 200, graph.text[:500] assert graph.json()["nodes"] assert all("subject_name" in edge for edge in graph.json()["edges"]) # ───────────────────────────────────────────────────────────────────────────── # Phase 0 verification — loopback guard on /bootstrap # ───────────────────────────────────────────────────────────────────────────── def test_bootstrap_remote_caller_hidden(client): """audit critical #1 fix (commit 9a3f22b): /bootstrap from a non- loopback client returns 404, never 200-with-admin-key. `TestClient`'s default client host is 'testclient', not '127.0.0.1' (https://www.starlette.io/testclient — the host is fabricated). So this test runs against the same code path a remote attacker would hit, and our `_require_loopback` guard should refuse.""" r = client.post("/api/v1/setup/bootstrap") assert r.status_code == 404, ( f"/bootstrap leaked from non-loopback host! " f"status={r.status_code}, body={r.text[:300]}" ) # ───────────────────────────────────────────────────────────────────────────── # Multi-tenant isolation — audit critical/high category # ───────────────────────────────────────────────────────────────────────────── def _insert_tenant_with_agent(db, tenant_name: str, agent_name: str) -> tuple[str, str]: """Helper: drop in a tenant + a single agent row. Returns (tid, aid).""" tid = gen_id("tn_") aid = gen_id("ag_") now = now_utc() db.execute( "INSERT INTO tenants (id, name, plan, status, created_at) " "VALUES (?, ?, 'free', 'active', ?)", (tid, tenant_name, now), ) db.execute( "INSERT INTO agents " "(id, tenant_id, name, description, status, created_at, updated_at) " "VALUES (?, ?, ?, '', 'active', ?, ?)", (aid, tid, agent_name, now, now), ) db.commit() return tid, aid def test_cross_tenant_get_agent_blocked(client, db, tenant_and_key): """Tenant A's key must NOT be able to GET tenant B's agent. audit #14 + #30: the `agents` and KB endpoints largely DO scope by `tenant_id`, but a single missing `AND tenant_id = ?` in any WHERE clause leaks cross-tenant data. Without this test the regression is invisible to CI.""" _, key_a = tenant_and_key _, aid_b = _insert_tenant_with_agent(db, "tenant-b", "tenant-b-private") r = client.get(f"/api/v1/agents/{aid_b}", headers=_auth(key_a)) # 403 (you're not allowed) OR 404 (we pretend it doesn't exist) — both # acceptable defenses; only 200 is a security regression. assert r.status_code in (403, 404), ( f"Cross-tenant read leaked! status={r.status_code}, body={r.text[:300]}" ) def test_cross_tenant_list_does_not_include_other_tenant(client, db, tenant_and_key): """Tenant A's /agents list must not include tenant B's rows. Catches the easier 'forgot WHERE tenant_id=?' typo on the list endpoint.""" _, key_a = tenant_and_key _, aid_b = _insert_tenant_with_agent(db, "tenant-b", "tenant-b-private") r = client.get("/api/v1/agents", headers=_auth(key_a)) assert r.status_code == 200, r.text[:300] body = r.json() items = body if isinstance(body, list) else ( body.get("items") or body.get("agents") or [] ) leaked = [a for a in items if a.get("id") == aid_b] assert not leaked, ( f"Tenant A's list leaked tenant B's agent! Got: {leaked}" ) # ───────────────────────────────────────────────────────────────────────────── # audit #26 — /analyze/* router auth + body size cap # ───────────────────────────────────────────────────────────────────────────── def test_analyze_lint_requires_auth(client): """audit #26: was `(req: AnalyzeRequest)` — no Depends(get_tenant). Free-compute / DoS amplifier. Now must 401 without a Bearer.""" r = client.post("/api/v1/analyze/lint", json={"config": "type: react"}) assert r.status_code == 401, ( f"/analyze/lint accepted unauthenticated request! got {r.status_code}" ) def test_analyze_lint_accepts_valid_key(client, tenant_and_key): _, key = tenant_and_key r = client.post("/api/v1/analyze/lint", json={"config": "type: react\nname: test"}, headers=_auth(key)) assert r.status_code == 200, r.text[:300] body = r.json() assert "framework" in body or "errors" in body def test_analyze_lint_rejects_oversize_yaml(client, tenant_and_key): """audit #26 + low #90: cap YAML body before yaml.safe_load to neuter billion-laughs alias amplification. Sending >256KB should 413.""" _, key = tenant_and_key huge = "type: react\nname: " + ("x" * (260 * 1024)) r = client.post("/api/v1/analyze/lint", json={"config": huge}, headers=_auth(key)) assert r.status_code == 413, ( f"expected 413 CONFIG_TOO_LARGE, got {r.status_code}: {r.text[:200]}" ) # ───────────────────────────────────────────────────────────────────────────── # audit #14 — rollback_agent RBAC + tenant scope # ───────────────────────────────────────────────────────────────────────────── def test_rollback_cross_tenant_returns_404(client, db, tenant_and_key): """Tenant A asks to rollback tenant B's agent → 404 (id oracle defense). Even if A guessed B's agent_id correctly, response is identical to 'agent does not exist'.""" _, key_a = tenant_and_key _, aid_b = _insert_tenant_with_agent(db, "tenant-b", "tenant-b-private") r = client.post(f"/api/v1/agents/{aid_b}/rollback", json={"target_version": 1}, headers=_auth(key_a)) assert r.status_code == 404, ( f"rollback across tenant boundary leaked! status={r.status_code}, " f"body={r.text[:300]}" ) def test_rollback_unknown_version_returns_404(client, db, tenant_and_key): """Tenant A asks to rollback their own agent to a version that doesn't exist → 404 VERSION_NOT_FOUND. Anchors the version-scoping query so we don't accidentally leak via a 5xx.""" tid_a, key_a = tenant_and_key aid_a = gen_id("ag_") now = now_utc() db.execute( "INSERT INTO agents " "(id, tenant_id, name, description, status, created_at, updated_at) " "VALUES (?, ?, ?, '', 'active', ?, ?)", (aid_a, tid_a, "my-agent", now, now), ) db.commit() r = client.post(f"/api/v1/agents/{aid_a}/rollback", json={"target_version": 99}, headers=_auth(key_a)) assert r.status_code == 404 # ───────────────────────────────────────────────────────────────────────────── # audit #27 — knowledge/{kb_id}/files path containment # ───────────────────────────────────────────────────────────────────────────── def test_add_files_rejects_path_outside_kb_root(client, db, tenant_and_key, tmp_path): """audit #27 fix: `/etc/passwd`-style host paths must be rejected even if the caller owns the KB. Containment check uses resolved absolute path + os.sep prefix.""" tid, key = tenant_and_key # Set up a KB whose root_dir is inside tmp_path so we can also test # the positive case. kb_id = gen_id("kb_") kb_root = tmp_path / "kb" kb_root.mkdir() (kb_root / "ok.txt").write_text("inside kb_root") # Build a file OUTSIDE the kb_root to attempt to register. outside = tmp_path / "outside.txt" outside.write_text("attacker would love to chunk this") now = now_utc() # knowledge_bases schema: id, tenant_id, name, root_dir, description, # created_at, updated_at (no `status` column) db.execute( "INSERT INTO knowledge_bases " "(id, tenant_id, name, root_dir, description, created_at, updated_at) " "VALUES (?, ?, ?, ?, '', ?, ?)", (kb_id, tid, "test-kb", str(kb_root), now, now), ) db.commit() # Try to add the outside file — should be rejected. r = client.post(f"/api/v1/knowledge/{kb_id}/files", json={"file_paths": [str(outside)]}, headers=_auth(key)) # Endpoint returns 201 Created on successful POST regardless of how # many files were ultimately registered; the rejection shows up in # the body's `rejected` array. assert r.status_code in (200, 201), r.text[:300] body = r.json() assert body["added"] == 0, f"escaped file was registered! body={body}" assert "rejected" in body, "expected `rejected` array listing the refusal" # Adding the inside file works (positive case). r2 = client.post(f"/api/v1/knowledge/{kb_id}/files", json={"file_paths": [str(kb_root / "ok.txt")]}, headers=_auth(key)) assert r2.status_code in (200, 201), r2.text[:300] assert r2.json()["added"] == 1 # ───────────────────────────────────────────────────────────────────────────── # audit #28 — /jobs/{job_id} IDOR # ───────────────────────────────────────────────────────────────────────────── def test_get_job_nonexistent_returns_404(client, tenant_and_key): """Sanity: unknown job_id → 404 (no 5xx, no leak).""" _, key = tenant_and_key r = client.get("/api/v1/jobs/job_doesnotexist", headers=_auth(key)) assert r.status_code == 404 def test_cancel_job_nonexistent_returns_404(client, tenant_and_key): """Same for cancel — used to silently 200 even for nonexistent IDs.""" _, key = tenant_and_key r = client.post("/api/v1/jobs/job_doesnotexist/cancel", headers=_auth(key)) assert r.status_code == 404