""" 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) def test_config_desktop_picks_visible_data_dir(tmp_path, monkeypatch): """CR FR-002: desktop users need a discoverable, branded data dir (`~/LambdAgentDesktop`), not a hidden `~/.agentpaas/data`. lab/paas keep the hidden dir to follow OS convention.""" monkeypatch.delenv("AGENTPAAS_DATA_DIR", raising=False) monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop") monkeypatch.setenv("HOME", str(tmp_path)) from agentpaas.config import AgentPaaSConfig cfg = AgentPaaSConfig() assert cfg.deployment_mode == "desktop" assert cfg.data_dir.endswith("LambdAgentDesktop"), cfg.data_dir monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "lab") cfg2 = AgentPaaSConfig() assert cfg2.deployment_mode == "lab" assert cfg2.data_dir.endswith(".agentpaas/data"), cfg2.data_dir def test_config_default_host_is_loopback_for_non_paas(monkeypatch, tmp_path): """Q12 + audit critical #1 same posture: desktop / lab default to 127.0.0.1 (locked down). paas defaults to 0.0.0.0 (behind reverse proxy in production). AGENTPAAS_HOST overrides in all cases.""" monkeypatch.delenv("AGENTPAAS_HOST", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) from agentpaas.config import AgentPaaSConfig monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop") assert AgentPaaSConfig().host == "127.0.0.1" monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "lab") assert AgentPaaSConfig().host == "127.0.0.1" monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "paas") assert AgentPaaSConfig().host == "0.0.0.0" # Explicit override wins in any mode (advanced user case). monkeypatch.setenv("AGENTPAAS_HOST", "0.0.0.0") monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop") assert AgentPaaSConfig().host == "0.0.0.0" # ───────────────────────────────────────────────────────────────────────────── # 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 # ───────────────────────────────────────────────────────────────────────────── # /api/v1/setup/mode — Q8 chip endpoint (unauthenticated) # ───────────────────────────────────────────────────────────────────────────── def test_setup_mode_endpoint_no_auth_required(client, db): """Q8: the mode chip renders BEFORE login (so the first-run wizard can adapt). /setup/mode must be reachable without a Bearer.""" r = client.get("/api/v1/setup/mode") assert r.status_code == 200, r.text[:200] body = r.json() assert "mode" in body and body["mode"] in ("desktop", "lab", "paas") assert "tenant_alias" in body assert "bootstrapped" in body def test_setup_mode_reflects_current_deployment_mode(client, db, mode): """Same client, different settings.deployment_mode → endpoint reports the current value (no caching).""" settings.deployment_mode = "paas" assert client.get("/api/v1/setup/mode").json()["mode"] == "paas" settings.deployment_mode = "lab" assert client.get("/api/v1/setup/mode").json()["mode"] == "lab" settings.deployment_mode = "desktop" assert client.get("/api/v1/setup/mode").json()["mode"] == "desktop" def test_setup_mode_reports_bootstrapped_false_on_fresh_install(client, db, tmp_path): """Fresh DB + no config file → bootstrapped=false. The first-run wizard depends on this signal to know whether to render itself. We have to patch CONFIG_FILE because the developer's real ~/.agentpaas/config.json may already contain an api_key from a real install; without the patch this test would assert against that file's contents.""" from agentpaas.api.v1 import setup as setup_mod original = setup_mod.CONFIG_FILE setup_mod.CONFIG_FILE = str(tmp_path / "config.json") try: r = client.get("/api/v1/setup/mode") body = r.json() assert body["bootstrapped"] is False assert body["tenant_alias"] == "" finally: setup_mod.CONFIG_FILE = original # ───────────────────────────────────────────────────────────────────────────── # 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 # ───────────────────────────────────────────────────────────────────────────── # FR-001 — desktop one-click login (/setup/desktop-key) # ───────────────────────────────────────────────────────────────────────────── def test_desktop_key_404_in_lab_mode(client, db, mode): """The one-click-login key endpoint must not exist outside desktop — lab/paas use the explicit SetupWizard paste flow. 404 hides it.""" settings.deployment_mode = "lab" r = client.get("/api/v1/setup/desktop-key") assert r.status_code == 404 def test_desktop_key_loopback_only(client, db, mode): """In desktop mode the endpoint is loopback-gated; TestClient's synthetic host is non-loopback → 404 (the secure default we rely on everywhere). The real desktop browser hits it from 127.0.0.1.""" settings.deployment_mode = "desktop" r = client.get("/api/v1/setup/desktop-key") # Non-loopback caller → 404 from _require_loopback. assert r.status_code == 404 # ───────────────────────────────────────────────────────────────────────────── # FR-002 — data-dir get/set (first-run wizard) # ───────────────────────────────────────────────────────────────────────────── def test_get_data_dir_reports_current(client, db): """GET /setup/data-dir returns the live data dir + subdirs + mode. TestClient's host is loopback-equivalent for our _require_loopback check (starlette uses 'testclient' which... is NOT loopback). So this actually exercises the loopback guard returning 404 from a non-local caller — which is the secure default. We assert that explicitly.""" r = client.get("/api/v1/setup/data-dir") # TestClient host is 'testclient' (non-loopback) → guard fires. assert r.status_code == 404 def test_set_data_dir_persists_and_requires_restart(tmp_path, monkeypatch): """PUT /setup/data-dir writes the choice to config.json and reports change_requires_restart. We call the handler logic via the module function path with CONFIG_FILE patched, since the HTTP layer is loopback-gated (covered above).""" from agentpaas.api.v1 import setup as setup_mod original = setup_mod.CONFIG_FILE setup_mod.CONFIG_FILE = str(tmp_path / "config.json") try: target = tmp_path / "MyResearchData" cfg = setup_mod._load_config() assert "data_dir" not in cfg # Simulate the PUT body-handling: validate + persist. cfg["data_dir"] = str(target) setup_mod._save_config(cfg) # config.py must now pick it up. import importlib import agentpaas.config as config_mod # Point config's _CONFIG_JSON at our temp file too. monkeypatch.setattr(config_mod, "_CONFIG_JSON", str(tmp_path / "config.json")) assert config_mod._data_dir_from_config_json() == str(target) # And it wins over the mode default. assert config_mod._default_data_dir("desktop") == str(target) finally: setup_mod.CONFIG_FILE = original # ───────────────────────────────────────────────────────────────────────────── # 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