|
|
@@ -0,0 +1,251 @@
|
|
|
+"""
|
|
|
+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)
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# 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}"
|
|
|
+ )
|