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