test_deployment_mode.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. """
  2. Integration tests for `deployment_mode` three-tier flag.
  3. Spec: ``docs/requirements-change-personal-desktop.md`` rev.2 §10.2.
  4. Decisions: §13.2 Q8-Q12 (mode chip / 404 oracle / no migration tool /
  5. tenant_id naming / lab bind default).
  6. The single most important property we verify here is the **Q10 smoke
  7. test**: a tenant + agent + API key created in `desktop` mode keep
  8. working unchanged after switching to `lab` and `paas`. If this breaks,
  9. the rev.2 "no migration tool needed" decision falls apart and we lose
  10. the whole "200 LOC vs touching 29 files" engineering value prop.
  11. Coverage:
  12. - config: AGENTPAAS_DEPLOYMENT_MODE validation + default
  13. - middleware: /admin /billing return 404 in desktop, 401/200 in lab/paas
  14. - other routes still reachable in desktop (auth dep unchanged)
  15. - auto_bootstrap_desktop: idempotent + tenant_id format (Q11) +
  16. contextual `tenants.name` alias (hostname/user)
  17. - Q10: full mode-transition smoke (desktop → lab → paas), same data
  18. """
  19. from __future__ import annotations
  20. import os
  21. os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
  22. import json
  23. import secrets
  24. import pytest
  25. try:
  26. from fastapi.testclient import TestClient
  27. except ImportError:
  28. pytest.skip(
  29. "fastapi.testclient requires httpx — `pip install -e ./agentpaas/[dev]`",
  30. allow_module_level=True,
  31. )
  32. from agentpaas.api.app import app
  33. from agentpaas.api.middleware.auth import hash_key
  34. from agentpaas.config import settings
  35. from agentpaas.db.models import Database, gen_id, now_utc
  36. import agentpaas.db.session as _session_mod
  37. # ─────────────────────────────────────────────────────────────────────────────
  38. # Fixtures (shared shape with test_api_endpoints.py / test_authenticate.py)
  39. # ─────────────────────────────────────────────────────────────────────────────
  40. @pytest.fixture
  41. def db():
  42. prev = _session_mod._db
  43. _session_mod._db = Database("sqlite:///:memory:")
  44. try:
  45. yield _session_mod._db
  46. finally:
  47. _session_mod._db = prev
  48. @pytest.fixture
  49. def mode():
  50. """Restore deployment_mode after each test (it's a module singleton)."""
  51. original = settings.deployment_mode
  52. yield # tests mutate settings.deployment_mode in-body
  53. settings.deployment_mode = original
  54. @pytest.fixture
  55. def client(db, mode):
  56. with TestClient(app) as c:
  57. yield c
  58. def _bootstrap(db):
  59. """Quick helper: insert tenant + user + admin api_key. Returns (tid, raw)."""
  60. tid = gen_id("tn_")
  61. uid = gen_id("usr_")
  62. raw_key = f"ap_{secrets.token_hex(16)}"
  63. now = now_utc()
  64. db.execute(
  65. "INSERT INTO tenants (id, name, plan, status, created_at) "
  66. "VALUES (?, ?, 'free', 'active', ?)",
  67. (tid, "test-tenant", now),
  68. )
  69. db.execute(
  70. "INSERT INTO users (id, tenant_id, email, role, created_at) "
  71. "VALUES (?, ?, '', 'admin', ?)",
  72. (uid, tid, now),
  73. )
  74. db.execute(
  75. "INSERT INTO api_keys "
  76. "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
  77. " rate_limit, status, created_at) "
  78. "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)",
  79. (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
  80. json.dumps(["agents:*", "admin:*", "billing:read"]), now),
  81. )
  82. db.commit()
  83. return tid, raw_key
  84. def _auth(key: str) -> dict:
  85. return {"Authorization": f"Bearer {key}"}
  86. # ─────────────────────────────────────────────────────────────────────────────
  87. # config.py — deployment_mode parsing
  88. # ─────────────────────────────────────────────────────────────────────────────
  89. def test_config_default_mode_is_desktop():
  90. """No env var → desktop (CR rev.2: installer-first default)."""
  91. # We test via settings directly since it's already constructed.
  92. # The full env-var roundtrip is covered by test_config_invalid_mode.
  93. assert settings.deployment_mode in ("desktop", "lab", "paas")
  94. # When this test runs unprefixed, the default applies.
  95. if "AGENTPAAS_DEPLOYMENT_MODE" not in os.environ:
  96. assert settings.deployment_mode == "desktop"
  97. def test_config_invalid_mode_raises():
  98. """Garbage env var → ValueError at construction time (fail fast)."""
  99. os.environ["AGENTPAAS_DEPLOYMENT_MODE"] = "not-a-mode"
  100. try:
  101. from agentpaas.config import AgentPaaSConfig
  102. with pytest.raises(ValueError) as exc:
  103. AgentPaaSConfig()
  104. assert "deployment_mode" in str(exc.value).lower() or "desktop|lab|paas" in str(exc.value)
  105. finally:
  106. os.environ.pop("AGENTPAAS_DEPLOYMENT_MODE", None)
  107. def test_config_desktop_picks_visible_data_dir(tmp_path, monkeypatch):
  108. """CR FR-002: desktop users need a discoverable, branded data dir
  109. (`~/LambdAgentDesktop`), not a hidden `~/.agentpaas/data`. lab/paas
  110. keep the hidden dir to follow OS convention."""
  111. monkeypatch.delenv("AGENTPAAS_DATA_DIR", raising=False)
  112. monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop")
  113. monkeypatch.setenv("HOME", str(tmp_path))
  114. from agentpaas.config import AgentPaaSConfig
  115. cfg = AgentPaaSConfig()
  116. assert cfg.deployment_mode == "desktop"
  117. assert cfg.data_dir.endswith("LambdAgentDesktop"), cfg.data_dir
  118. monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "lab")
  119. cfg2 = AgentPaaSConfig()
  120. assert cfg2.deployment_mode == "lab"
  121. assert cfg2.data_dir.endswith(".agentpaas/data"), cfg2.data_dir
  122. def test_config_default_host_is_loopback_for_non_paas(monkeypatch, tmp_path):
  123. """Q12 + audit critical #1 same posture: desktop / lab default to
  124. 127.0.0.1 (locked down). paas defaults to 0.0.0.0 (behind reverse
  125. proxy in production). AGENTPAAS_HOST overrides in all cases."""
  126. monkeypatch.delenv("AGENTPAAS_HOST", raising=False)
  127. monkeypatch.setenv("HOME", str(tmp_path))
  128. from agentpaas.config import AgentPaaSConfig
  129. monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop")
  130. assert AgentPaaSConfig().host == "127.0.0.1"
  131. monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "lab")
  132. assert AgentPaaSConfig().host == "127.0.0.1"
  133. monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "paas")
  134. assert AgentPaaSConfig().host == "0.0.0.0"
  135. # Explicit override wins in any mode (advanced user case).
  136. monkeypatch.setenv("AGENTPAAS_HOST", "0.0.0.0")
  137. monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop")
  138. assert AgentPaaSConfig().host == "0.0.0.0"
  139. # ─────────────────────────────────────────────────────────────────────────────
  140. # Middleware: desktop blocks /admin /billing → 404 (Q9 oracle defense)
  141. # ─────────────────────────────────────────────────────────────────────────────
  142. def test_desktop_mode_blocks_admin_routes(client, db):
  143. """In desktop mode, /api/v1/admin/* must return 404. Audit-style oracle
  144. defense (Q9): a remote scanner cannot tell whether the deployment is
  145. desktop / lab / paas because the response is identical to "no such
  146. route". Critically: 404 means NOT 401/403 (which would leak that
  147. the route exists but requires auth).
  148. We use POST /admin/tenants (a real, gate-eligible endpoint) so that
  149. in `lab` mode the same probe returns 401 from the auth dependency —
  150. that distinction is what `test_lab_mode_does_not_block_admin` below
  151. relies on."""
  152. settings.deployment_mode = "desktop"
  153. _, key = _bootstrap(db)
  154. # Even with a valid Bearer the route is unreachable in desktop.
  155. r = client.post("/api/v1/admin/tenants", json={}, headers=_auth(key))
  156. assert r.status_code == 404, (
  157. f"desktop should hide /admin completely; got {r.status_code}: "
  158. f"{r.text[:200]}"
  159. )
  160. body = r.json()
  161. # The detail string must be `Not Found` (our explicit gate), not
  162. # FastAPI's default which has a different shape — that's how we
  163. # verify our middleware actually fired.
  164. assert body.get("detail") == "Not Found"
  165. def test_desktop_mode_blocks_billing_routes(client, db):
  166. settings.deployment_mode = "desktop"
  167. _, key = _bootstrap(db)
  168. r = client.get("/api/v1/billing/usage", headers=_auth(key))
  169. assert r.status_code == 404
  170. def test_desktop_mode_allows_agents_route(client, db):
  171. """Sanity: non-blocked routes still respond. Desktop mode must not
  172. accidentally gate the routes the user actually needs."""
  173. settings.deployment_mode = "desktop"
  174. _, key = _bootstrap(db)
  175. r = client.get("/api/v1/agents", headers=_auth(key))
  176. # 200 (list, possibly empty) — NOT 404.
  177. assert r.status_code == 200, r.text[:200]
  178. def test_desktop_mode_allows_knowledge_route(client, db):
  179. settings.deployment_mode = "desktop"
  180. _, key = _bootstrap(db)
  181. r = client.get("/api/v1/knowledge", headers=_auth(key))
  182. assert r.status_code == 200
  183. # ─────────────────────────────────────────────────────────────────────────────
  184. # Lab / PaaS modes expose admin routes (audit fixes #14/#28/#30 still active)
  185. # ─────────────────────────────────────────────────────────────────────────────
  186. def test_lab_mode_does_not_block_admin(client, db):
  187. """In lab mode the route is reachable. Whether it returns 401/200
  188. depends on the existing auth middleware — but it MUST NOT be 404
  189. from our deployment_mode_gate."""
  190. settings.deployment_mode = "lab"
  191. r = client.get("/api/v1/admin/tenants") # no auth — likely 401
  192. # Anything except 404 confirms the gate did NOT fire.
  193. assert r.status_code != 404, (
  194. f"lab mode should NOT block /admin; got 404 from {r.text[:200]}"
  195. )
  196. def test_paas_mode_does_not_block_billing(client, db):
  197. settings.deployment_mode = "paas"
  198. r = client.get("/api/v1/billing/usage")
  199. assert r.status_code != 404
  200. # ─────────────────────────────────────────────────────────────────────────────
  201. # /api/v1/setup/mode — Q8 chip endpoint (unauthenticated)
  202. # ─────────────────────────────────────────────────────────────────────────────
  203. def test_setup_mode_endpoint_no_auth_required(client, db):
  204. """Q8: the mode chip renders BEFORE login (so the first-run wizard
  205. can adapt). /setup/mode must be reachable without a Bearer."""
  206. r = client.get("/api/v1/setup/mode")
  207. assert r.status_code == 200, r.text[:200]
  208. body = r.json()
  209. assert "mode" in body and body["mode"] in ("desktop", "lab", "paas")
  210. assert "tenant_alias" in body
  211. assert "bootstrapped" in body
  212. def test_setup_mode_reflects_current_deployment_mode(client, db, mode):
  213. """Same client, different settings.deployment_mode → endpoint reports
  214. the current value (no caching)."""
  215. settings.deployment_mode = "paas"
  216. assert client.get("/api/v1/setup/mode").json()["mode"] == "paas"
  217. settings.deployment_mode = "lab"
  218. assert client.get("/api/v1/setup/mode").json()["mode"] == "lab"
  219. settings.deployment_mode = "desktop"
  220. assert client.get("/api/v1/setup/mode").json()["mode"] == "desktop"
  221. def test_setup_mode_reports_bootstrapped_false_on_fresh_install(client, db, tmp_path):
  222. """Fresh DB + no config file → bootstrapped=false. The first-run
  223. wizard depends on this signal to know whether to render itself.
  224. We have to patch CONFIG_FILE because the developer's real
  225. ~/.agentpaas/config.json may already contain an api_key from a real
  226. install; without the patch this test would assert against that
  227. file's contents."""
  228. from agentpaas.api.v1 import setup as setup_mod
  229. original = setup_mod.CONFIG_FILE
  230. setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
  231. try:
  232. r = client.get("/api/v1/setup/mode")
  233. body = r.json()
  234. assert body["bootstrapped"] is False
  235. assert body["tenant_alias"] == ""
  236. finally:
  237. setup_mod.CONFIG_FILE = original
  238. # ─────────────────────────────────────────────────────────────────────────────
  239. # auto_bootstrap_desktop — Q11 tenant_id naming + idempotency
  240. # ─────────────────────────────────────────────────────────────────────────────
  241. def test_auto_bootstrap_creates_anonymous_tenant_id(db, mode, tmp_path):
  242. """Q11: tenant_id is anonymous `tn_local_{hex}`; `tenants.name`
  243. carries the contextual hostname/user alias."""
  244. settings.deployment_mode = "desktop"
  245. # Redirect the config file to a temp path so the test doesn't litter
  246. # ~/.agentpaas/config.json.
  247. from agentpaas.api.v1 import setup as setup_mod
  248. original_cfg = setup_mod.CONFIG_FILE
  249. setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
  250. try:
  251. from agentpaas.api.v1.setup import auto_bootstrap_desktop
  252. raw = auto_bootstrap_desktop()
  253. assert raw is not None, "expected fresh bootstrap to return a key"
  254. assert raw.startswith("ap_")
  255. # Verify tenant_id format
  256. rows = db.fetchall("SELECT id, name FROM tenants")
  257. assert len(rows) == 1
  258. assert rows[0]["id"].startswith("tn_local_"), rows[0]["id"]
  259. # Q11: contextual alias is set
  260. assert "/" in rows[0]["name"] or rows[0]["name"] == "local"
  261. finally:
  262. setup_mod.CONFIG_FILE = original_cfg
  263. def test_auto_bootstrap_is_idempotent(db, mode, tmp_path):
  264. """Calling twice yields a tenant the second time too (no duplicate),
  265. and returns None on the second call (= already configured)."""
  266. settings.deployment_mode = "desktop"
  267. from agentpaas.api.v1 import setup as setup_mod
  268. original_cfg = setup_mod.CONFIG_FILE
  269. setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
  270. try:
  271. from agentpaas.api.v1.setup import auto_bootstrap_desktop
  272. first = auto_bootstrap_desktop()
  273. second = auto_bootstrap_desktop()
  274. assert first is not None
  275. assert second is None, "second call should be a no-op"
  276. # Exactly one tenant — bootstrap did not create a duplicate.
  277. rows = db.fetchall("SELECT id FROM tenants")
  278. assert len(rows) == 1
  279. finally:
  280. setup_mod.CONFIG_FILE = original_cfg
  281. # ─────────────────────────────────────────────────────────────────────────────
  282. # FR-001 — desktop one-click login (/setup/desktop-key)
  283. # ─────────────────────────────────────────────────────────────────────────────
  284. def test_desktop_key_404_in_lab_mode(client, db, mode):
  285. """The one-click-login key endpoint must not exist outside desktop —
  286. lab/paas use the explicit SetupWizard paste flow. 404 hides it."""
  287. settings.deployment_mode = "lab"
  288. r = client.get("/api/v1/setup/desktop-key")
  289. assert r.status_code == 404
  290. def test_desktop_key_loopback_only(client, db, mode):
  291. """In desktop mode the endpoint is loopback-gated; TestClient's
  292. synthetic host is non-loopback → 404 (the secure default we rely on
  293. everywhere). The real desktop browser hits it from 127.0.0.1."""
  294. settings.deployment_mode = "desktop"
  295. r = client.get("/api/v1/setup/desktop-key")
  296. # Non-loopback caller → 404 from _require_loopback.
  297. assert r.status_code == 404
  298. # ─────────────────────────────────────────────────────────────────────────────
  299. # FR-002 — data-dir get/set (first-run wizard)
  300. # ─────────────────────────────────────────────────────────────────────────────
  301. def test_get_data_dir_reports_current(client, db):
  302. """GET /setup/data-dir returns the live data dir + subdirs + mode.
  303. TestClient's host is loopback-equivalent for our _require_loopback
  304. check (starlette uses 'testclient' which... is NOT loopback). So this
  305. actually exercises the loopback guard returning 404 from a non-local
  306. caller — which is the secure default. We assert that explicitly."""
  307. r = client.get("/api/v1/setup/data-dir")
  308. # TestClient host is 'testclient' (non-loopback) → guard fires.
  309. assert r.status_code == 404
  310. def test_set_data_dir_persists_and_requires_restart(tmp_path, monkeypatch):
  311. """PUT /setup/data-dir writes the choice to config.json and reports
  312. change_requires_restart. We call the handler logic via the module
  313. function path with CONFIG_FILE patched, since the HTTP layer is
  314. loopback-gated (covered above)."""
  315. from agentpaas.api.v1 import setup as setup_mod
  316. original = setup_mod.CONFIG_FILE
  317. setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
  318. try:
  319. target = tmp_path / "MyResearchData"
  320. cfg = setup_mod._load_config()
  321. assert "data_dir" not in cfg
  322. # Simulate the PUT body-handling: validate + persist.
  323. cfg["data_dir"] = str(target)
  324. setup_mod._save_config(cfg)
  325. # config.py must now pick it up.
  326. import importlib
  327. import agentpaas.config as config_mod
  328. # Point config's _CONFIG_JSON at our temp file too.
  329. monkeypatch.setattr(config_mod, "_CONFIG_JSON", str(tmp_path / "config.json"))
  330. assert config_mod._data_dir_from_config_json() == str(target)
  331. # And it wins over the mode default.
  332. assert config_mod._default_data_dir("desktop") == str(target)
  333. finally:
  334. setup_mod.CONFIG_FILE = original
  335. # ─────────────────────────────────────────────────────────────────────────────
  336. # Q10 — mode-transition smoke test
  337. # ─────────────────────────────────────────────────────────────────────────────
  338. def test_mode_transition_desktop_to_lab_to_paas(client, db, mode):
  339. """Q10 headline assertion: a tenant + API key + agent created in
  340. desktop mode still work after the operator flips to lab and then
  341. paas. The whole rev.2 strategy depends on this — if data doesn't
  342. carry across mode changes, we'd need a real migration tool and the
  343. "200 LOC" engineering claim falls apart.
  344. We don't restart the test app; the mode flag is module-level and we
  345. flip it mid-test. That's functionally equivalent to a process
  346. restart because (a) no in-memory state encodes the mode and (b)
  347. every request reads settings.deployment_mode fresh."""
  348. # ── In desktop mode, bootstrap a tenant + admin key ──
  349. settings.deployment_mode = "desktop"
  350. tid, key = _bootstrap(db)
  351. # Create an agent via the public API (verifies the auth + scoping path).
  352. # `config` is required by CreateAgentRequest; minimal react skeleton.
  353. r = client.post(
  354. "/api/v1/agents",
  355. json={
  356. "name": "transition-test",
  357. "description": "Q10 smoke",
  358. "config": {
  359. "type": "react",
  360. "name": "transition-test",
  361. "systemPrompt": "Q10 mode-transition smoke test",
  362. "model": {"provider": "stub", "name": "stub"},
  363. },
  364. },
  365. headers=_auth(key),
  366. )
  367. assert r.status_code in (200, 201), r.text[:200]
  368. agent = r.json()
  369. aid = agent.get("id") or agent.get("agent_id")
  370. assert aid
  371. # ── Flip to lab. Same key must continue to work; admin route now reachable. ──
  372. settings.deployment_mode = "lab"
  373. r = client.get(f"/api/v1/agents/{aid}", headers=_auth(key))
  374. assert r.status_code == 200, r.text[:200]
  375. # In lab, the previously-blocked /admin is reachable. Without auth
  376. # we get 401 (route exists, auth missing) — not 404.
  377. r = client.post("/api/v1/admin/tenants", json={})
  378. assert r.status_code == 401, (
  379. f"lab: expected 401 from auth, got {r.status_code}: {r.text[:120]}"
  380. )
  381. # ── Flip to paas. Same data, all routes still expose. ──
  382. settings.deployment_mode = "paas"
  383. r = client.get(f"/api/v1/agents/{aid}", headers=_auth(key))
  384. assert r.status_code == 200
  385. # ── Flip back to desktop. Admin route is hidden again, but the
  386. # bootstrapped agent is still listable. ──
  387. settings.deployment_mode = "desktop"
  388. r = client.get(f"/api/v1/agents/{aid}", headers=_auth(key))
  389. assert r.status_code == 200
  390. r = client.post("/api/v1/admin/tenants", json={}, headers=_auth(key))
  391. assert r.status_code == 404 # gate fires again