| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439 |
- """
- 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
|