test_api_endpoints.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. """
  2. HTTP-level integration tests for the agentpaas FastAPI surface.
  3. audit/AUDIT_2026-06-05.md critical #6 baseline:
  4. $ grep -rln "TestClient\|httpx" tests/
  5. (no matches)
  6. 3,477 LOC of route handlers had ZERO route-level coverage before this
  7. file existed. A `tenant_id` filter typo could ship to production and
  8. CI would still be green. This file is the bootstrap skeleton — it
  9. covers the *most expensive* failure modes (auth bypass, cross-tenant
  10. read, loopback guard) so the framework is in place; subsequent PRs
  11. should fan out per-route happy + sad paths from here.
  12. Conventions for new tests:
  13. - Use the `client` fixture (TestClient bound to an isolated in-memory
  14. SQLite DB).
  15. - Use `tenant_and_key` for an authenticated request.
  16. - For cross-tenant tests, build the second tenant inline via the `db`
  17. fixture exposed alongside.
  18. - Assertions favor *what must not happen* over *exact response shape*;
  19. we are testing security boundaries, not API ergonomics.
  20. """
  21. from __future__ import annotations
  22. # IMPORTANT: the env var has to be set BEFORE agentpaas.config is imported,
  23. # because settings.database_url is read at module-import time. conftest.py
  24. # runs first (Python imports), and *this* module's top-level os.environ
  25. # call lands before the `from agentpaas...` lines below.
  26. import os
  27. os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
  28. import json
  29. import secrets
  30. import pytest
  31. try:
  32. from fastapi.testclient import TestClient
  33. except ImportError: # httpx not installed — skip entire file gracefully
  34. pytest.skip(
  35. "fastapi.testclient requires httpx — `pip install -e ./agentpaas/[dev]`",
  36. allow_module_level=True,
  37. )
  38. from agentpaas.api.app import app
  39. from agentpaas.api.middleware.auth import hash_key
  40. from agentpaas.db.models import Database, gen_id, now_utc
  41. import agentpaas.db.session as _session_mod
  42. # ─────────────────────────────────────────────────────────────────────────────
  43. # Fixtures
  44. # ─────────────────────────────────────────────────────────────────────────────
  45. @pytest.fixture
  46. def db():
  47. """Fresh in-memory SQLite per test. Database.__init__ re-runs all
  48. migrations + the zombie-reaper migration; a brand-new `:memory:` DB
  49. is guaranteed clean. We swap the module-level singleton in
  50. `agentpaas.db.session` so both `Depends(get_database)` AND the
  51. direct `get_db()` calls inside `authenticate()` see the same DB."""
  52. prev = _session_mod._db
  53. _session_mod._db = Database("sqlite:///:memory:")
  54. try:
  55. yield _session_mod._db
  56. finally:
  57. _session_mod._db = prev
  58. @pytest.fixture
  59. def client(db):
  60. """TestClient bound to the isolated DB. We don't override any
  61. dependencies — by patching `_session_mod._db`, every code path that
  62. calls `get_db()` (Depends, or direct) hits the test DB."""
  63. with TestClient(app) as c:
  64. yield c
  65. @pytest.fixture
  66. def tenant_and_key(db):
  67. """Insert a tenant + a user + an active admin-scope API key.
  68. Returns (tenant_id, raw_api_key). The raw key is shown ONCE — same
  69. contract as production /bootstrap."""
  70. tid = gen_id("tn_")
  71. uid = gen_id("usr_")
  72. key = f"ap_{secrets.token_hex(16)}"
  73. now = now_utc()
  74. db.execute(
  75. "INSERT INTO tenants (id, name, plan, status, created_at) "
  76. "VALUES (?, ?, 'free', 'active', ?)",
  77. (tid, "test-tenant", now),
  78. )
  79. db.execute(
  80. "INSERT INTO users (id, tenant_id, email, role, created_at) "
  81. "VALUES (?, ?, '', 'admin', ?)",
  82. (uid, tid, now),
  83. )
  84. db.execute(
  85. "INSERT INTO api_keys "
  86. "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
  87. " rate_limit, status, created_at) "
  88. "VALUES (?, ?, ?, ?, ?, 'test-key', ?, 600, 'active', ?)",
  89. (
  90. gen_id("key_"), tid, uid, hash_key(key), key[:8],
  91. json.dumps(["agents:*", "keys:*", "admin:*"]), now,
  92. ),
  93. )
  94. db.commit()
  95. return tid, key
  96. def _auth(key: str) -> dict:
  97. return {"Authorization": f"Bearer {key}"}
  98. # ─────────────────────────────────────────────────────────────────────────────
  99. # Health / no-auth surface
  100. # ─────────────────────────────────────────────────────────────────────────────
  101. def test_setup_health_no_auth(client):
  102. """SetupWizard polls /api/v1/setup/health before bootstrap — must be
  103. reachable without a key, and must not leak DB state."""
  104. r = client.get("/api/v1/setup/health")
  105. assert r.status_code == 200
  106. body = r.json()
  107. assert body.get("ok") is True
  108. # Should not include things like raw env vars / paths / tenant counts
  109. assert "secret" not in str(body).lower()
  110. assert "password" not in str(body).lower()
  111. # ─────────────────────────────────────────────────────────────────────────────
  112. # Auth dependency — critical surface, was untested
  113. # ─────────────────────────────────────────────────────────────────────────────
  114. def test_list_agents_requires_auth(client):
  115. """No Authorization header → 401. audit #14 root cause: handlers
  116. without `Depends(get_tenant)` slip through this gate silently;
  117. this test enforces it on /agents."""
  118. r = client.get("/api/v1/agents")
  119. assert r.status_code == 401, (
  120. f"Expected 401 for missing auth, got {r.status_code}: {r.text[:300]}"
  121. )
  122. def test_list_agents_rejects_bad_key(client):
  123. """Garbage Bearer → 401, not 500. Catches both 'key absent in DB'
  124. and 'authenticate() raised unhandled' regressions."""
  125. r = client.get("/api/v1/agents", headers=_auth("ap_doesnotexist_0000000000000000"))
  126. assert r.status_code == 401, (
  127. f"Expected 401 for invalid key, got {r.status_code}: {r.text[:300]}"
  128. )
  129. def test_list_agents_accepts_valid_key(client, tenant_and_key):
  130. """Happy path: real key → 200 + list payload. Anchors the
  131. authenticate() success path; without this, the legacy-hash auto-
  132. migration logic (audit SEC-01 leftover at api/middleware/auth.py
  133. around _hash_key_legacy) is structurally untested."""
  134. _, key = tenant_and_key
  135. r = client.get("/api/v1/agents", headers=_auth(key))
  136. assert r.status_code == 200, r.text[:300]
  137. body = r.json()
  138. # tolerant: response may be a bare list or {"items": [...]} or {"agents": [...]}
  139. items = body if isinstance(body, list) else (
  140. body.get("items") or body.get("agents") or []
  141. )
  142. assert isinstance(items, list)
  143. # ─────────────────────────────────────────────────────────────────────────────
  144. # OntoRefactor governance vertical slice
  145. # ─────────────────────────────────────────────────────────────────────────────
  146. def test_governance_requires_auth(client):
  147. r = client.get("/api/v1/governance/projects")
  148. assert r.status_code == 401
  149. def test_governance_bootstrap_and_agent_ingest(client, tenant_and_key):
  150. _, key = tenant_and_key
  151. seeded = client.post(
  152. "/api/v1/governance/bootstrap/tenant-isolation", headers=_auth(key)
  153. )
  154. assert seeded.status_code == 200, seeded.text[:500]
  155. body = seeded.json()
  156. project_id = body["project"]["id"]
  157. assert body["validation"]["valid"] is True
  158. assert body["overview"]["layers"]["M3"] == 8
  159. analysis = client.post(
  160. f"/api/v1/governance/projects/{project_id}/agent-runs/analyze",
  161. headers=_auth(key),
  162. json={
  163. "source_type": "ddl",
  164. "source_name": "orders.sql",
  165. "environment": "development",
  166. "content": "CREATE TABLE dcp_order (id BIGINT PRIMARY KEY, amount DECIMAL(12,2));",
  167. },
  168. )
  169. assert analysis.status_code == 200, analysis.text[:500]
  170. assert analysis.json()["status"] == "completed"
  171. assert len(analysis.json()["trace"]) >= 5
  172. graph = client.get(
  173. f"/api/v1/governance/projects/{project_id}/graph", headers=_auth(key)
  174. )
  175. assert graph.status_code == 200, graph.text[:500]
  176. assert graph.json()["nodes"]
  177. assert all("subject_name" in edge for edge in graph.json()["edges"])
  178. # ─────────────────────────────────────────────────────────────────────────────
  179. # Phase 0 verification — loopback guard on /bootstrap
  180. # ─────────────────────────────────────────────────────────────────────────────
  181. def test_bootstrap_remote_caller_hidden(client):
  182. """audit critical #1 fix (commit 9a3f22b): /bootstrap from a non-
  183. loopback client returns 404, never 200-with-admin-key.
  184. `TestClient`'s default client host is 'testclient', not '127.0.0.1'
  185. (https://www.starlette.io/testclient — the host is fabricated). So
  186. this test runs against the same code path a remote attacker would
  187. hit, and our `_require_loopback` guard should refuse."""
  188. r = client.post("/api/v1/setup/bootstrap")
  189. assert r.status_code == 404, (
  190. f"/bootstrap leaked from non-loopback host! "
  191. f"status={r.status_code}, body={r.text[:300]}"
  192. )
  193. # ─────────────────────────────────────────────────────────────────────────────
  194. # Multi-tenant isolation — audit critical/high category
  195. # ─────────────────────────────────────────────────────────────────────────────
  196. def _insert_tenant_with_agent(db, tenant_name: str, agent_name: str) -> tuple[str, str]:
  197. """Helper: drop in a tenant + a single agent row. Returns (tid, aid)."""
  198. tid = gen_id("tn_")
  199. aid = gen_id("ag_")
  200. now = now_utc()
  201. db.execute(
  202. "INSERT INTO tenants (id, name, plan, status, created_at) "
  203. "VALUES (?, ?, 'free', 'active', ?)",
  204. (tid, tenant_name, now),
  205. )
  206. db.execute(
  207. "INSERT INTO agents "
  208. "(id, tenant_id, name, description, status, created_at, updated_at) "
  209. "VALUES (?, ?, ?, '', 'active', ?, ?)",
  210. (aid, tid, agent_name, now, now),
  211. )
  212. db.commit()
  213. return tid, aid
  214. def test_cross_tenant_get_agent_blocked(client, db, tenant_and_key):
  215. """Tenant A's key must NOT be able to GET tenant B's agent.
  216. audit #14 + #30: the `agents` and KB endpoints largely DO scope by
  217. `tenant_id`, but a single missing `AND tenant_id = ?` in any WHERE
  218. clause leaks cross-tenant data. Without this test the regression is
  219. invisible to CI."""
  220. _, key_a = tenant_and_key
  221. _, aid_b = _insert_tenant_with_agent(db, "tenant-b", "tenant-b-private")
  222. r = client.get(f"/api/v1/agents/{aid_b}", headers=_auth(key_a))
  223. # 403 (you're not allowed) OR 404 (we pretend it doesn't exist) — both
  224. # acceptable defenses; only 200 is a security regression.
  225. assert r.status_code in (403, 404), (
  226. f"Cross-tenant read leaked! status={r.status_code}, body={r.text[:300]}"
  227. )
  228. def test_cross_tenant_list_does_not_include_other_tenant(client, db, tenant_and_key):
  229. """Tenant A's /agents list must not include tenant B's rows.
  230. Catches the easier 'forgot WHERE tenant_id=?' typo on the list
  231. endpoint."""
  232. _, key_a = tenant_and_key
  233. _, aid_b = _insert_tenant_with_agent(db, "tenant-b", "tenant-b-private")
  234. r = client.get("/api/v1/agents", headers=_auth(key_a))
  235. assert r.status_code == 200, r.text[:300]
  236. body = r.json()
  237. items = body if isinstance(body, list) else (
  238. body.get("items") or body.get("agents") or []
  239. )
  240. leaked = [a for a in items if a.get("id") == aid_b]
  241. assert not leaked, (
  242. f"Tenant A's list leaked tenant B's agent! Got: {leaked}"
  243. )
  244. # ─────────────────────────────────────────────────────────────────────────────
  245. # audit #26 — /analyze/* router auth + body size cap
  246. # ─────────────────────────────────────────────────────────────────────────────
  247. def test_analyze_lint_requires_auth(client):
  248. """audit #26: was `(req: AnalyzeRequest)` — no Depends(get_tenant).
  249. Free-compute / DoS amplifier. Now must 401 without a Bearer."""
  250. r = client.post("/api/v1/analyze/lint", json={"config": "type: react"})
  251. assert r.status_code == 401, (
  252. f"/analyze/lint accepted unauthenticated request! got {r.status_code}"
  253. )
  254. def test_analyze_lint_accepts_valid_key(client, tenant_and_key):
  255. _, key = tenant_and_key
  256. r = client.post("/api/v1/analyze/lint",
  257. json={"config": "type: react\nname: test"},
  258. headers=_auth(key))
  259. assert r.status_code == 200, r.text[:300]
  260. body = r.json()
  261. assert "framework" in body or "errors" in body
  262. def test_analyze_lint_rejects_oversize_yaml(client, tenant_and_key):
  263. """audit #26 + low #90: cap YAML body before yaml.safe_load to neuter
  264. billion-laughs alias amplification. Sending >256KB should 413."""
  265. _, key = tenant_and_key
  266. huge = "type: react\nname: " + ("x" * (260 * 1024))
  267. r = client.post("/api/v1/analyze/lint",
  268. json={"config": huge},
  269. headers=_auth(key))
  270. assert r.status_code == 413, (
  271. f"expected 413 CONFIG_TOO_LARGE, got {r.status_code}: {r.text[:200]}"
  272. )
  273. # ─────────────────────────────────────────────────────────────────────────────
  274. # audit #14 — rollback_agent RBAC + tenant scope
  275. # ─────────────────────────────────────────────────────────────────────────────
  276. def test_rollback_cross_tenant_returns_404(client, db, tenant_and_key):
  277. """Tenant A asks to rollback tenant B's agent → 404 (id oracle defense).
  278. Even if A guessed B's agent_id correctly, response is identical to
  279. 'agent does not exist'."""
  280. _, key_a = tenant_and_key
  281. _, aid_b = _insert_tenant_with_agent(db, "tenant-b", "tenant-b-private")
  282. r = client.post(f"/api/v1/agents/{aid_b}/rollback",
  283. json={"target_version": 1},
  284. headers=_auth(key_a))
  285. assert r.status_code == 404, (
  286. f"rollback across tenant boundary leaked! status={r.status_code}, "
  287. f"body={r.text[:300]}"
  288. )
  289. def test_rollback_unknown_version_returns_404(client, db, tenant_and_key):
  290. """Tenant A asks to rollback their own agent to a version that
  291. doesn't exist → 404 VERSION_NOT_FOUND. Anchors the version-scoping
  292. query so we don't accidentally leak via a 5xx."""
  293. tid_a, key_a = tenant_and_key
  294. aid_a = gen_id("ag_")
  295. now = now_utc()
  296. db.execute(
  297. "INSERT INTO agents "
  298. "(id, tenant_id, name, description, status, created_at, updated_at) "
  299. "VALUES (?, ?, ?, '', 'active', ?, ?)",
  300. (aid_a, tid_a, "my-agent", now, now),
  301. )
  302. db.commit()
  303. r = client.post(f"/api/v1/agents/{aid_a}/rollback",
  304. json={"target_version": 99},
  305. headers=_auth(key_a))
  306. assert r.status_code == 404
  307. # ─────────────────────────────────────────────────────────────────────────────
  308. # audit #27 — knowledge/{kb_id}/files path containment
  309. # ─────────────────────────────────────────────────────────────────────────────
  310. def test_add_files_rejects_path_outside_kb_root(client, db, tenant_and_key, tmp_path):
  311. """audit #27 fix: `/etc/passwd`-style host paths must be rejected
  312. even if the caller owns the KB. Containment check uses resolved
  313. absolute path + os.sep prefix."""
  314. tid, key = tenant_and_key
  315. # Set up a KB whose root_dir is inside tmp_path so we can also test
  316. # the positive case.
  317. kb_id = gen_id("kb_")
  318. kb_root = tmp_path / "kb"
  319. kb_root.mkdir()
  320. (kb_root / "ok.txt").write_text("inside kb_root")
  321. # Build a file OUTSIDE the kb_root to attempt to register.
  322. outside = tmp_path / "outside.txt"
  323. outside.write_text("attacker would love to chunk this")
  324. now = now_utc()
  325. # knowledge_bases schema: id, tenant_id, name, root_dir, description,
  326. # created_at, updated_at (no `status` column)
  327. db.execute(
  328. "INSERT INTO knowledge_bases "
  329. "(id, tenant_id, name, root_dir, description, created_at, updated_at) "
  330. "VALUES (?, ?, ?, ?, '', ?, ?)",
  331. (kb_id, tid, "test-kb", str(kb_root), now, now),
  332. )
  333. db.commit()
  334. # Try to add the outside file — should be rejected.
  335. r = client.post(f"/api/v1/knowledge/{kb_id}/files",
  336. json={"file_paths": [str(outside)]},
  337. headers=_auth(key))
  338. # Endpoint returns 201 Created on successful POST regardless of how
  339. # many files were ultimately registered; the rejection shows up in
  340. # the body's `rejected` array.
  341. assert r.status_code in (200, 201), r.text[:300]
  342. body = r.json()
  343. assert body["added"] == 0, f"escaped file was registered! body={body}"
  344. assert "rejected" in body, "expected `rejected` array listing the refusal"
  345. # Adding the inside file works (positive case).
  346. r2 = client.post(f"/api/v1/knowledge/{kb_id}/files",
  347. json={"file_paths": [str(kb_root / "ok.txt")]},
  348. headers=_auth(key))
  349. assert r2.status_code in (200, 201), r2.text[:300]
  350. assert r2.json()["added"] == 1
  351. # ─────────────────────────────────────────────────────────────────────────────
  352. # audit #28 — /jobs/{job_id} IDOR
  353. # ─────────────────────────────────────────────────────────────────────────────
  354. def test_get_job_nonexistent_returns_404(client, tenant_and_key):
  355. """Sanity: unknown job_id → 404 (no 5xx, no leak)."""
  356. _, key = tenant_and_key
  357. r = client.get("/api/v1/jobs/job_doesnotexist", headers=_auth(key))
  358. assert r.status_code == 404
  359. def test_cancel_job_nonexistent_returns_404(client, tenant_and_key):
  360. """Same for cancel — used to silently 200 even for nonexistent IDs."""
  361. _, key = tenant_and_key
  362. r = client.post("/api/v1/jobs/job_doesnotexist/cancel", headers=_auth(key))
  363. assert r.status_code == 404