|
@@ -0,0 +1,326 @@
|
|
|
|
|
+"""
|
|
|
|
|
+Integration tests for `deployment_mode` three-tier flag.
|
|
|
|
|
+
|
|
|
|
|
+Spec: ``docs/requirements-change-personal-desktop.md`` rev.2 §10.2.
|
|
|
|
|
+Decisions: §13.2 Q8-Q12 (mode chip / 404 oracle / no migration tool /
|
|
|
|
|
+tenant_id naming / lab bind default).
|
|
|
|
|
+
|
|
|
|
|
+The single most important property we verify here is the **Q10 smoke
|
|
|
|
|
+test**: a tenant + agent + API key created in `desktop` mode keep
|
|
|
|
|
+working unchanged after switching to `lab` and `paas`. If this breaks,
|
|
|
|
|
+the rev.2 "no migration tool needed" decision falls apart and we lose
|
|
|
|
|
+the whole "200 LOC vs touching 29 files" engineering value prop.
|
|
|
|
|
+
|
|
|
|
|
+Coverage:
|
|
|
|
|
+ - config: AGENTPAAS_DEPLOYMENT_MODE validation + default
|
|
|
|
|
+ - middleware: /admin /billing return 404 in desktop, 401/200 in lab/paas
|
|
|
|
|
+ - other routes still reachable in desktop (auth dep unchanged)
|
|
|
|
|
+ - auto_bootstrap_desktop: idempotent + tenant_id format (Q11) +
|
|
|
|
|
+ contextual `tenants.name` alias (hostname/user)
|
|
|
|
|
+ - Q10: full mode-transition smoke (desktop → lab → paas), same data
|
|
|
|
|
+"""
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import os
|
|
|
|
|
+os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
|
|
|
|
|
+
|
|
|
|
|
+import json
|
|
|
|
|
+import secrets
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+
|
|
|
|
|
+try:
|
|
|
|
|
+ from fastapi.testclient import TestClient
|
|
|
|
|
+except ImportError:
|
|
|
|
|
+ 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.config import settings
|
|
|
|
|
+from agentpaas.db.models import Database, gen_id, now_utc
|
|
|
|
|
+import agentpaas.db.session as _session_mod
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+# Fixtures (shared shape with test_api_endpoints.py / test_authenticate.py)
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+def db():
|
|
|
|
|
+ prev = _session_mod._db
|
|
|
|
|
+ _session_mod._db = Database("sqlite:///:memory:")
|
|
|
|
|
+ try:
|
|
|
|
|
+ yield _session_mod._db
|
|
|
|
|
+ finally:
|
|
|
|
|
+ _session_mod._db = prev
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+def mode():
|
|
|
|
|
+ """Restore deployment_mode after each test (it's a module singleton)."""
|
|
|
|
|
+ original = settings.deployment_mode
|
|
|
|
|
+ yield # tests mutate settings.deployment_mode in-body
|
|
|
|
|
+ settings.deployment_mode = original
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+def client(db, mode):
|
|
|
|
|
+ with TestClient(app) as c:
|
|
|
|
|
+ yield c
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _bootstrap(db):
|
|
|
|
|
+ """Quick helper: insert tenant + user + admin api_key. Returns (tid, raw)."""
|
|
|
|
|
+ tid = gen_id("tn_")
|
|
|
|
|
+ uid = gen_id("usr_")
|
|
|
|
|
+ raw_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', ?, 600, 'active', ?)",
|
|
|
|
|
+ (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
|
|
|
|
|
+ json.dumps(["agents:*", "admin:*", "billing:read"]), now),
|
|
|
|
|
+ )
|
|
|
|
|
+ db.commit()
|
|
|
|
|
+ return tid, raw_key
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _auth(key: str) -> dict:
|
|
|
|
|
+ return {"Authorization": f"Bearer {key}"}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+# config.py — deployment_mode parsing
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def test_config_default_mode_is_desktop():
|
|
|
|
|
+ """No env var → desktop (CR rev.2: installer-first default)."""
|
|
|
|
|
+ # We test via settings directly since it's already constructed.
|
|
|
|
|
+ # The full env-var roundtrip is covered by test_config_invalid_mode.
|
|
|
|
|
+ assert settings.deployment_mode in ("desktop", "lab", "paas")
|
|
|
|
|
+ # When this test runs unprefixed, the default applies.
|
|
|
|
|
+ if "AGENTPAAS_DEPLOYMENT_MODE" not in os.environ:
|
|
|
|
|
+ assert settings.deployment_mode == "desktop"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_config_invalid_mode_raises():
|
|
|
|
|
+ """Garbage env var → ValueError at construction time (fail fast)."""
|
|
|
|
|
+ os.environ["AGENTPAAS_DEPLOYMENT_MODE"] = "not-a-mode"
|
|
|
|
|
+ try:
|
|
|
|
|
+ from agentpaas.config import AgentPaaSConfig
|
|
|
|
|
+ with pytest.raises(ValueError) as exc:
|
|
|
|
|
+ AgentPaaSConfig()
|
|
|
|
|
+ assert "deployment_mode" in str(exc.value).lower() or "desktop|lab|paas" in str(exc.value)
|
|
|
|
|
+ finally:
|
|
|
|
|
+ os.environ.pop("AGENTPAAS_DEPLOYMENT_MODE", None)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+# Middleware: desktop blocks /admin /billing → 404 (Q9 oracle defense)
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def test_desktop_mode_blocks_admin_routes(client, db):
|
|
|
|
|
+ """In desktop mode, /api/v1/admin/* must return 404. Audit-style oracle
|
|
|
|
|
+ defense (Q9): a remote scanner cannot tell whether the deployment is
|
|
|
|
|
+ desktop / lab / paas because the response is identical to "no such
|
|
|
|
|
+ route". Critically: 404 means NOT 401/403 (which would leak that
|
|
|
|
|
+ the route exists but requires auth).
|
|
|
|
|
+
|
|
|
|
|
+ We use POST /admin/tenants (a real, gate-eligible endpoint) so that
|
|
|
|
|
+ in `lab` mode the same probe returns 401 from the auth dependency —
|
|
|
|
|
+ that distinction is what `test_lab_mode_does_not_block_admin` below
|
|
|
|
|
+ relies on."""
|
|
|
|
|
+ settings.deployment_mode = "desktop"
|
|
|
|
|
+ _, key = _bootstrap(db)
|
|
|
|
|
+
|
|
|
|
|
+ # Even with a valid Bearer the route is unreachable in desktop.
|
|
|
|
|
+ r = client.post("/api/v1/admin/tenants", json={}, headers=_auth(key))
|
|
|
|
|
+ assert r.status_code == 404, (
|
|
|
|
|
+ f"desktop should hide /admin completely; got {r.status_code}: "
|
|
|
|
|
+ f"{r.text[:200]}"
|
|
|
|
|
+ )
|
|
|
|
|
+ body = r.json()
|
|
|
|
|
+ # The detail string must be `Not Found` (our explicit gate), not
|
|
|
|
|
+ # FastAPI's default which has a different shape — that's how we
|
|
|
|
|
+ # verify our middleware actually fired.
|
|
|
|
|
+ assert body.get("detail") == "Not Found"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_desktop_mode_blocks_billing_routes(client, db):
|
|
|
|
|
+ settings.deployment_mode = "desktop"
|
|
|
|
|
+ _, key = _bootstrap(db)
|
|
|
|
|
+ r = client.get("/api/v1/billing/usage", headers=_auth(key))
|
|
|
|
|
+ assert r.status_code == 404
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_desktop_mode_allows_agents_route(client, db):
|
|
|
|
|
+ """Sanity: non-blocked routes still respond. Desktop mode must not
|
|
|
|
|
+ accidentally gate the routes the user actually needs."""
|
|
|
|
|
+ settings.deployment_mode = "desktop"
|
|
|
|
|
+ _, key = _bootstrap(db)
|
|
|
|
|
+ r = client.get("/api/v1/agents", headers=_auth(key))
|
|
|
|
|
+ # 200 (list, possibly empty) — NOT 404.
|
|
|
|
|
+ assert r.status_code == 200, r.text[:200]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_desktop_mode_allows_knowledge_route(client, db):
|
|
|
|
|
+ settings.deployment_mode = "desktop"
|
|
|
|
|
+ _, key = _bootstrap(db)
|
|
|
|
|
+ r = client.get("/api/v1/knowledge", headers=_auth(key))
|
|
|
|
|
+ assert r.status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+# Lab / PaaS modes expose admin routes (audit fixes #14/#28/#30 still active)
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def test_lab_mode_does_not_block_admin(client, db):
|
|
|
|
|
+ """In lab mode the route is reachable. Whether it returns 401/200
|
|
|
|
|
+ depends on the existing auth middleware — but it MUST NOT be 404
|
|
|
|
|
+ from our deployment_mode_gate."""
|
|
|
|
|
+ settings.deployment_mode = "lab"
|
|
|
|
|
+ r = client.get("/api/v1/admin/tenants") # no auth — likely 401
|
|
|
|
|
+ # Anything except 404 confirms the gate did NOT fire.
|
|
|
|
|
+ assert r.status_code != 404, (
|
|
|
|
|
+ f"lab mode should NOT block /admin; got 404 from {r.text[:200]}"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_paas_mode_does_not_block_billing(client, db):
|
|
|
|
|
+ settings.deployment_mode = "paas"
|
|
|
|
|
+ r = client.get("/api/v1/billing/usage")
|
|
|
|
|
+ assert r.status_code != 404
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+# auto_bootstrap_desktop — Q11 tenant_id naming + idempotency
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def test_auto_bootstrap_creates_anonymous_tenant_id(db, mode, tmp_path):
|
|
|
|
|
+ """Q11: tenant_id is anonymous `tn_local_{hex}`; `tenants.name`
|
|
|
|
|
+ carries the contextual hostname/user alias."""
|
|
|
|
|
+ settings.deployment_mode = "desktop"
|
|
|
|
|
+
|
|
|
|
|
+ # Redirect the config file to a temp path so the test doesn't litter
|
|
|
|
|
+ # ~/.agentpaas/config.json.
|
|
|
|
|
+ from agentpaas.api.v1 import setup as setup_mod
|
|
|
|
|
+ original_cfg = setup_mod.CONFIG_FILE
|
|
|
|
|
+ setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
|
|
|
|
|
+ try:
|
|
|
|
|
+ from agentpaas.api.v1.setup import auto_bootstrap_desktop
|
|
|
|
|
+ raw = auto_bootstrap_desktop()
|
|
|
|
|
+ assert raw is not None, "expected fresh bootstrap to return a key"
|
|
|
|
|
+ assert raw.startswith("ap_")
|
|
|
|
|
+
|
|
|
|
|
+ # Verify tenant_id format
|
|
|
|
|
+ rows = db.fetchall("SELECT id, name FROM tenants")
|
|
|
|
|
+ assert len(rows) == 1
|
|
|
|
|
+ assert rows[0]["id"].startswith("tn_local_"), rows[0]["id"]
|
|
|
|
|
+ # Q11: contextual alias is set
|
|
|
|
|
+ assert "/" in rows[0]["name"] or rows[0]["name"] == "local"
|
|
|
|
|
+ finally:
|
|
|
|
|
+ setup_mod.CONFIG_FILE = original_cfg
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_auto_bootstrap_is_idempotent(db, mode, tmp_path):
|
|
|
|
|
+ """Calling twice yields a tenant the second time too (no duplicate),
|
|
|
|
|
+ and returns None on the second call (= already configured)."""
|
|
|
|
|
+ settings.deployment_mode = "desktop"
|
|
|
|
|
+
|
|
|
|
|
+ from agentpaas.api.v1 import setup as setup_mod
|
|
|
|
|
+ original_cfg = setup_mod.CONFIG_FILE
|
|
|
|
|
+ setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
|
|
|
|
|
+ try:
|
|
|
|
|
+ from agentpaas.api.v1.setup import auto_bootstrap_desktop
|
|
|
|
|
+ first = auto_bootstrap_desktop()
|
|
|
|
|
+ second = auto_bootstrap_desktop()
|
|
|
|
|
+ assert first is not None
|
|
|
|
|
+ assert second is None, "second call should be a no-op"
|
|
|
|
|
+
|
|
|
|
|
+ # Exactly one tenant — bootstrap did not create a duplicate.
|
|
|
|
|
+ rows = db.fetchall("SELECT id FROM tenants")
|
|
|
|
|
+ assert len(rows) == 1
|
|
|
|
|
+ finally:
|
|
|
|
|
+ setup_mod.CONFIG_FILE = original_cfg
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+# Q10 — mode-transition smoke test
|
|
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def test_mode_transition_desktop_to_lab_to_paas(client, db, mode):
|
|
|
|
|
+ """Q10 headline assertion: a tenant + API key + agent created in
|
|
|
|
|
+ desktop mode still work after the operator flips to lab and then
|
|
|
|
|
+ paas. The whole rev.2 strategy depends on this — if data doesn't
|
|
|
|
|
+ carry across mode changes, we'd need a real migration tool and the
|
|
|
|
|
+ "200 LOC" engineering claim falls apart.
|
|
|
|
|
+
|
|
|
|
|
+ We don't restart the test app; the mode flag is module-level and we
|
|
|
|
|
+ flip it mid-test. That's functionally equivalent to a process
|
|
|
|
|
+ restart because (a) no in-memory state encodes the mode and (b)
|
|
|
|
|
+ every request reads settings.deployment_mode fresh."""
|
|
|
|
|
+ # ── In desktop mode, bootstrap a tenant + admin key ──
|
|
|
|
|
+ settings.deployment_mode = "desktop"
|
|
|
|
|
+ tid, key = _bootstrap(db)
|
|
|
|
|
+
|
|
|
|
|
+ # Create an agent via the public API (verifies the auth + scoping path).
|
|
|
|
|
+ # `config` is required by CreateAgentRequest; minimal react skeleton.
|
|
|
|
|
+ r = client.post(
|
|
|
|
|
+ "/api/v1/agents",
|
|
|
|
|
+ json={
|
|
|
|
|
+ "name": "transition-test",
|
|
|
|
|
+ "description": "Q10 smoke",
|
|
|
|
|
+ "config": {
|
|
|
|
|
+ "type": "react",
|
|
|
|
|
+ "name": "transition-test",
|
|
|
|
|
+ "systemPrompt": "Q10 mode-transition smoke test",
|
|
|
|
|
+ "model": {"provider": "stub", "name": "stub"},
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ headers=_auth(key),
|
|
|
|
|
+ )
|
|
|
|
|
+ assert r.status_code in (200, 201), r.text[:200]
|
|
|
|
|
+ agent = r.json()
|
|
|
|
|
+ aid = agent.get("id") or agent.get("agent_id")
|
|
|
|
|
+ assert aid
|
|
|
|
|
+
|
|
|
|
|
+ # ── Flip to lab. Same key must continue to work; admin route now reachable. ──
|
|
|
|
|
+ settings.deployment_mode = "lab"
|
|
|
|
|
+ r = client.get(f"/api/v1/agents/{aid}", headers=_auth(key))
|
|
|
|
|
+ assert r.status_code == 200, r.text[:200]
|
|
|
|
|
+
|
|
|
|
|
+ # In lab, the previously-blocked /admin is reachable. Without auth
|
|
|
|
|
+ # we get 401 (route exists, auth missing) — not 404.
|
|
|
|
|
+ r = client.post("/api/v1/admin/tenants", json={})
|
|
|
|
|
+ assert r.status_code == 401, (
|
|
|
|
|
+ f"lab: expected 401 from auth, got {r.status_code}: {r.text[:120]}"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # ── Flip to paas. Same data, all routes still expose. ──
|
|
|
|
|
+ settings.deployment_mode = "paas"
|
|
|
|
|
+ r = client.get(f"/api/v1/agents/{aid}", headers=_auth(key))
|
|
|
|
|
+ assert r.status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+ # ── Flip back to desktop. Admin route is hidden again, but the
|
|
|
|
|
+ # bootstrapped agent is still listable. ──
|
|
|
|
|
+ settings.deployment_mode = "desktop"
|
|
|
|
|
+ r = client.get(f"/api/v1/agents/{aid}", headers=_auth(key))
|
|
|
|
|
+ assert r.status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+ r = client.post("/api/v1/admin/tenants", json={}, headers=_auth(key))
|
|
|
|
|
+ assert r.status_code == 404 # gate fires again
|