Files
Octop/tests/integration/test_setup_wizard.py
王愈甫 66e4cfce88 fix(infra): relocate wizard password file under OCTOP_HOME with legacy fallback (#2171) (#2174)
* fix(infra): relocate wizard password file under OCTOP_HOME with legacy fallback

Derive the wizard password location from PathLayout root (OCTOP_HOME)
instead of hardcoding Path.home(). When OCTOP_HOME is relocated, the
password file is created and resolved from <OCTOP_HOME>/octop-login.txt.

To preserve complete backward compatibility for existing installations,
resolve_password_path, ensure_password, and read_password maintain a
fallback to legacy ~/octop-login.txt, migrating and synchronizing the
secret on boot when upgrading so no duplicate secrets are generated.

Closes #2171

* docs(changelog): record octop-login.txt OCTOP_HOME relocation fix (#2171)
2026-10-19 14:24:49 +08:00

449 lines
16 KiB
Python

"""Integration tests for the 4-step wizard backend.
Covers: verify-password, token-protected initial-admin, finish endpoint,
and the 410 behavior on completed setups.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
import pytest
from octop.infra.setup.password_file import WIZARD_FILE_NAME, read_password
from tests.support.app import octop_client, write_octop_config
@pytest.fixture
async def env(patched_app_client):
yield patched_app_client
# ─── verify-password ────────────────────────────────────────────────
async def test_verify_password_returns_token_on_match(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
assert pw is not None
r = await c.post("/api/setup/verify-password", json={"password": pw})
assert r.status_code == 200
body = r.json()
assert isinstance(body["wizard_token"], str)
assert body["expires_in"] > 0
async def test_verify_password_rejects_mismatch(env: Any) -> None:
c, _srv, _home = env
r = await c.post("/api/setup/verify-password", json={"password": "wrong"})
assert r.status_code == 401
assert r.json()["error"]["code"] == "SETUP_PASSWORD_WRONG"
async def test_verify_password_rate_limited(env: Any) -> None:
c, _srv, _home = env
for _ in range(5):
await c.post("/api/setup/verify-password", json={"password": "x"})
r = await c.post("/api/setup/verify-password", json={"password": "x"})
assert r.status_code == 429
assert r.json()["error"]["code"] == "SETUP_RATE_LIMITED"
# ─── initial-admin token guard ──────────────────────────────────────
async def test_initial_admin_requires_wizard_token(env: Any) -> None:
c, _srv, _home = env
r = await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12"},
)
assert r.status_code == 401
assert r.json()["error"]["code"] == "SETUP_TOKEN_INVALID"
async def test_initial_admin_succeeds_with_token(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
r = await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12"},
headers={"Authorization": f"Bearer {tok}"},
)
assert r.status_code == 201
body = r.json()
assert isinstance(body["access_token"], str)
# The wizard password file is kept permanently, even after setup completes.
assert (home / WIZARD_FILE_NAME).exists()
async def test_initial_admin_rejects_weak_password(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
r = await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "pw"},
headers={"Authorization": f"Bearer {tok}"},
)
assert r.status_code == 400
assert r.json()["error"]["code"] == "PASSWORD_TOO_WEAK"
async def test_initial_admin_respects_locale_body(env: Any) -> None:
c, srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
r = await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12", "locale": "en"},
headers={"Authorization": f"Bearer {tok}"},
)
assert r.status_code == 201
assert r.json()["locale"] == "en"
user = srv.user_manager.get("admin")
assert user is not None
assert user.locale == "en"
# ─── /setup/status ─────────────────────────────────────────────────
async def test_status_reports_wizard_password_exists(env: Any) -> None:
c, _srv, home = env
r = await c.get("/api/setup/status")
body = r.json()
assert body["setup_required"] is True
assert body["wizard_password_required"] is True
assert body["wizard_password_exists"] is True
assert body["wizard_password_path"] == str(home / WIZARD_FILE_NAME)
async def test_begin_issues_token_when_password_not_required(tmp_octop_home: Path) -> None:
write_octop_config(tmp_octop_home, require_setup_password=False)
async with octop_client(tmp_octop_home) as (c, _srv):
r = await c.get("/api/setup/status")
body = r.json()
assert body["wizard_password_required"] is False
assert body["wizard_password_exists"] is False
assert body["wizard_password_path"] is None
r = await c.post("/api/setup/begin")
assert r.status_code == 200
assert r.json()["wizard_token"]
async def test_validate_token_rejects_missing_header(env: Any) -> None:
c, _srv, _home = env
r = await c.get("/api/setup/validate-token")
assert r.status_code == 200
assert r.json()["valid"] is False
async def test_validate_token_accepts_fresh_token(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
r = await c.get(
"/api/setup/validate-token",
headers={"Authorization": f"Bearer {tok}"},
)
assert r.status_code == 200
assert r.json()["valid"] is True
async def test_validate_token_rejects_stale_token(env: Any) -> None:
c, _srv, _home = env
r = await c.get(
"/api/setup/validate-token",
headers={"Authorization": "Bearer stale-token"},
)
assert r.status_code == 200
assert r.json()["valid"] is False
# ─── /setup/finish ─────────────────────────────────────────────────
async def test_finish_requires_wizard_token(env: Any) -> None:
c, _srv, _home = env
r = await c.post("/api/setup/finish", json={"provider_draft": None})
assert r.status_code == 401
async def test_finish_returns_ok_with_valid_token(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
r = await c.post(
"/api/setup/finish",
json={"provider_draft": None},
headers={"Authorization": f"Bearer {tok}"},
)
assert r.status_code == 200
assert r.json()["ok"] is True
async def test_finish_works_after_admin_created(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12"},
headers={"Authorization": f"Bearer {tok}"},
)
r = await c.post(
"/api/setup/finish",
json={"provider_draft": None},
headers={"Authorization": f"Bearer {tok}"},
)
assert r.status_code == 200
assert r.json()["ok"] is True
async def test_validate_token_still_valid_after_admin_created(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12"},
headers={"Authorization": f"Bearer {tok}"},
)
r = await c.get(
"/api/setup/validate-token",
headers={"Authorization": f"Bearer {tok}"},
)
assert r.status_code == 200
assert r.json()["valid"] is True
async def test_test_provider_requires_wizard_token(env: Any) -> None:
c, _srv, _home = env
r = await c.post(
"/api/setup/test-provider",
json={
"name": "OpenAI",
"type": "openai",
"api_key": "sk-test",
"model_id": "gpt-4o-mini",
},
)
assert r.status_code == 401
async def test_test_provider_returns_error_for_bad_key(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
r = await c.post(
"/api/setup/test-provider",
json={
"name": "OpenAI",
"type": "openai",
"api_key": "sk-invalid",
"model_id": "gpt-4o-mini",
},
headers={"Authorization": f"Bearer {tok}"},
)
assert r.status_code == 200
assert r.json()["ok"] is False
def _assert_resume_wizard_absent(response: Any) -> None:
"""A removed POST is 404, or 405 when the GET-only SPA catch-all is mounted."""
assert response.status_code in (404, 405)
assert "wizard_token" not in response.text
async def test_resume_wizard_does_not_issue_token(env: Any) -> None:
c, _srv, home = env
_assert_resume_wizard_absent(await c.post("/api/setup/resume-wizard"))
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12"},
headers={"Authorization": f"Bearer {tok}"},
)
_assert_resume_wizard_absent(await c.post("/api/setup/resume-wizard"))
still = await c.get(
"/api/setup/validate-token",
headers={"Authorization": f"Bearer {tok}"},
)
assert still.status_code == 200
assert still.json()["valid"] is True
async def test_test_provider_accepts_admin_jwt_after_admin_created(env: Any) -> None:
c, srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
admin = (
await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12"},
headers={"Authorization": f"Bearer {tok}"},
)
).json()
assert "access_token" in admin
srv.wizard_tokens.clear()
r = await c.post(
"/api/setup/test-provider",
json={
"name": "OpenAI",
"type": "openai",
"api_key": "sk-invalid",
"model_id": "gpt-4o-mini",
},
headers={"Authorization": f"Bearer {admin['access_token']}"},
)
assert r.status_code == 200
assert r.json()["ok"] is False
async def test_finish_saves_provider_with_admin_jwt(env: Any) -> None:
c, srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
admin = (
await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12"},
headers={"Authorization": f"Bearer {tok}"},
)
).json()
srv.wizard_tokens.clear()
r = await c.post(
"/api/setup/finish",
json={
"provider_draft": {
"name": "HAI",
"type": "openai",
"api_key": "sk-test",
"base_url": "https://api.example.com/v1",
"models": [
{
"id": "MiniMax-M2.7",
"name": "MiniMax",
"enabled": True,
"input": ["text"],
}
],
}
},
headers={"Authorization": f"Bearer {admin['access_token']}"},
)
assert r.status_code == 200, r.text
providers = srv.services.provider_repo.list_all()
assert len(providers) == 1
assert providers[0].api_key == "sk-test"
provider_name, model_id = srv.services.settings_repo.get_active_model()
assert provider_name == "HAI"
assert model_id == "MiniMax-M2.7"
registry = srv.app_runtime.agent_registry
assert registry._harness_manager is not None
assert registry._harness_manager.shared_factory is not None
main = srv.services.repos.agent_repo.get("main")
assert main is not None
# The default agent boots asynchronously after finish(); wait for it to settle.
last_state = main.last_state
for _ in range(50):
await asyncio.sleep(0.1)
if (
current := srv.services.repos.agent_repo.get("main")
) and current.last_state == "running":
last_state = current.last_state
break
if current is not None:
last_state = current.last_state
assert last_state == "running"
# ─── 410 guard ─────────────────────────────────────────────────────
async def test_setup_410_after_admin_exists(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12"},
headers={"Authorization": f"Bearer {tok}"},
)
# Plant a stale file to verify the 410 guard does not touch it.
(home / WIZARD_FILE_NAME).write_text("stale\n", encoding="utf-8")
r = await c.post("/api/setup/verify-password", json={"password": "stale"})
assert r.status_code == 410
# The wizard password file is never deleted by the app, even on the 410 path.
assert (home / WIZARD_FILE_NAME).exists()
# ─── lockdown middleware ───────────────────────────────────────────
async def test_lockdown_blocks_non_setup_endpoints_when_no_users(env: Any) -> None:
c, _srv, _home = env
r = await c.get("/api/agents")
assert r.status_code == 503
body = r.json()
assert body.get("setup_required") is True
async def test_lockdown_allows_setup_endpoints(env: Any) -> None:
c, _srv, _home = env
r = await c.get("/api/setup/status")
assert r.status_code == 200
async def test_lockdown_allows_health_path(env: Any) -> None:
c, _srv, _home = env
r = await c.get("/api/health")
assert r.status_code == 200
async def test_lockdown_lifts_after_admin_created(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12"},
headers={"Authorization": f"Bearer {tok}"},
)
r = await c.get("/api/agents")
# No JWT ⇒ 401 (auth, not lockdown).
assert r.status_code == 401
async def test_finish_rejects_invalid_token_after_admin_exists(env: Any) -> None:
c, _srv, home = env
pw = read_password(home)
tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"]
await c.post(
"/api/setup/initial-admin",
json={"username": "admin", "password": "TestPass12"},
headers={"Authorization": f"Bearer {tok}"},
)
r = await c.post(
"/api/setup/finish",
json={"provider_draft": None},
headers={"Authorization": "Bearer faketoken"},
)
assert r.status_code == 401
async def test_wizard_password_relocated_to_octop_home(env: Any) -> None:
_c, srv, home = env
assert srv.paths.root == home
assert (home / WIZARD_FILE_NAME).is_file()
assert not (Path.home() / WIZARD_FILE_NAME).exists()
assert read_password(home) is not None