From f47e16aa61e98f78c712a17efe45b08c6cd503c3 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Wed, 15 Jul 2026 14:13:35 +0200 Subject: [PATCH] refactor(admin): remove the unreachable /api/setup endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated-password bootstrap now sets an admin password on every fresh node, so the /admin/api/setup success path is unreachable — it 409s ("already set") on any booted node. No caller exists across the admin UI, routstr-cli, routstr-sdk, routstrd, or routstr-chat, so the endpoint (and its SetupRequest model) are dead surface. First-run is now: the generated password is printed once at boot, log in via /admin, and change it from the dashboard. Co-Authored-By: Claude Opus 4.8 --- routstr/core/admin.py | 23 ------------ tests/integration/test_admin_auth.py | 56 +++++++++------------------- 2 files changed, 18 insertions(+), 61 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 02b81611..66a1d288 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -310,29 +310,6 @@ async def update_nsec(request: Request, payload: NsecUpdate) -> dict[str, object return {"ok": True, "npub": npub} -class SetupRequest(BaseModel): - password: str - - -@admin_router.post("/api/setup") -async def initial_setup(request: Request, payload: SetupRequest) -> dict[str, object]: - async with create_session() as session: - secret = await get_secret(session) - if secret.admin_password_hash: - raise HTTPException(status_code=409, detail="Admin password already set") - pw = (payload.password or "").strip() - if len(pw) < vault.MIN_PASSWORD_LENGTH: - raise HTTPException( - status_code=400, - detail=( - "Password must be at least " - f"{vault.MIN_PASSWORD_LENGTH} characters" - ), - ) - await set_admin_password(session, pw) - return {"ok": True} - - class AdminLoginRequest(BaseModel): password: str diff --git a/tests/integration/test_admin_auth.py b/tests/integration/test_admin_auth.py index e7743d45..6f2b912a 100644 --- a/tests/integration/test_admin_auth.py +++ b/tests/integration/test_admin_auth.py @@ -1,11 +1,11 @@ """Tests for admin password auth backed by the hashed Secret store (issue #553). -Login, password change and first-run setup verify against the one-way -``Secret.admin_password_hash`` (scrypt) instead of a plaintext settings field, -which also closes the old ``!=`` timing-attack comparison. The flow is exercised -end-to-end through the public admin endpoints: setup writes the first hash, -login checks against it, and a password change re-hashes so the old password -stops working and the new one starts. +Login and password change verify against the one-way ``Secret.admin_password_hash`` +(scrypt) instead of a plaintext settings field, which also closes the old ``!=`` +timing-attack comparison. The first hash is written by ``bootstrap_secrets`` at +startup (generated, or migrated from a legacy password); here it is seeded +directly into the store, then login checks against it and a password change +re-hashes so the old password stops working and the new one starts. """ from __future__ import annotations @@ -13,10 +13,14 @@ from __future__ import annotations import pytest from httpx import AsyncClient, Response +from routstr.core.db import create_session, set_admin_password -async def _setup_password(client: AsyncClient, password: str) -> None: - resp = await client.post("/admin/api/setup", json={"password": password}) - assert resp.status_code == 200, resp.text + +async def _seed_password(password: str) -> None: + # bootstrap_secrets owns first-password creation at startup; tests seed the + # hash straight into the store the same way, then exercise login/change. + async with create_session() as session: + await set_admin_password(session, password) async def _login(client: AsyncClient, password: str) -> Response: @@ -40,7 +44,7 @@ async def test_login_500_when_no_password_configured( async def test_login_succeeds_with_correct_password( integration_client: AsyncClient, ) -> None: - await _setup_password(integration_client, "correct horse") + await _seed_password("correct horse") resp = await _login(integration_client, "correct horse") assert resp.status_code == 200 body = resp.json() @@ -53,35 +57,11 @@ async def test_login_succeeds_with_correct_password( async def test_login_rejects_wrong_password( integration_client: AsyncClient, ) -> None: - await _setup_password(integration_client, "correct horse") + await _seed_password("correct horse") resp = await _login(integration_client, "wrong horse") assert resp.status_code == 401 -# --- first-run setup ------------------------------------------------------- - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_setup_rejects_short_password( - integration_client: AsyncClient, -) -> None: - resp = await integration_client.post("/admin/api/setup", json={"password": "short"}) - assert resp.status_code == 400 - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_setup_409_when_password_already_set( - integration_client: AsyncClient, -) -> None: - await _setup_password(integration_client, "first password") - resp = await integration_client.post( - "/admin/api/setup", json={"password": "second password"} - ) - assert resp.status_code == 409 - - # --- password change ------------------------------------------------------- @@ -90,7 +70,7 @@ async def test_setup_409_when_password_already_set( async def test_update_password_rehashes_so_only_new_works( integration_client: AsyncClient, ) -> None: - await _setup_password(integration_client, "old password") + await _seed_password("old password") login = await _login(integration_client, "old password") token = login.json()["token"] integration_client.headers["Authorization"] = f"Bearer {token}" @@ -112,7 +92,7 @@ async def test_update_password_rehashes_so_only_new_works( async def test_update_password_rejects_wrong_current( integration_client: AsyncClient, ) -> None: - await _setup_password(integration_client, "old password") + await _seed_password("old password") login = await _login(integration_client, "old password") token = login.json()["token"] integration_client.headers["Authorization"] = f"Bearer {token}" @@ -129,7 +109,7 @@ async def test_update_password_rejects_wrong_current( async def test_update_password_rejects_short_new( integration_client: AsyncClient, ) -> None: - await _setup_password(integration_client, "old password") + await _seed_password("old password") login = await _login(integration_client, "old password") token = login.json()["token"] integration_client.headers["Authorization"] = f"Bearer {token}"