mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 02:54:37 +00:00
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 <noreply@anthropic.com>
122 lines
4.1 KiB
Python
122 lines
4.1 KiB
Python
"""Tests for admin password auth backed by the hashed Secret store (issue #553).
|
|
|
|
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
|
|
|
|
import pytest
|
|
from httpx import AsyncClient, Response
|
|
|
|
from routstr.core.db import create_session, set_admin_password
|
|
|
|
|
|
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:
|
|
return await client.post("/admin/api/login", json={"password": password})
|
|
|
|
|
|
# --- login -----------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_login_500_when_no_password_configured(
|
|
integration_client: AsyncClient,
|
|
) -> None:
|
|
resp = await _login(integration_client, "anything")
|
|
assert resp.status_code == 500
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_login_succeeds_with_correct_password(
|
|
integration_client: AsyncClient,
|
|
) -> None:
|
|
await _seed_password("correct horse")
|
|
resp = await _login(integration_client, "correct horse")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["ok"] is True
|
|
assert isinstance(body["token"], str) and body["token"]
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_login_rejects_wrong_password(
|
|
integration_client: AsyncClient,
|
|
) -> None:
|
|
await _seed_password("correct horse")
|
|
resp = await _login(integration_client, "wrong horse")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
# --- password change -------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_update_password_rehashes_so_only_new_works(
|
|
integration_client: AsyncClient,
|
|
) -> None:
|
|
await _seed_password("old password")
|
|
login = await _login(integration_client, "old password")
|
|
token = login.json()["token"]
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
|
|
resp = await integration_client.patch(
|
|
"/admin/api/password",
|
|
json={"current_password": "old password", "new_password": "new password"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
# Drop admin auth so the login calls aren't treated as authenticated noise.
|
|
integration_client.headers.pop("Authorization", None)
|
|
assert (await _login(integration_client, "old password")).status_code == 401
|
|
assert (await _login(integration_client, "new password")).status_code == 200
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_update_password_rejects_wrong_current(
|
|
integration_client: AsyncClient,
|
|
) -> None:
|
|
await _seed_password("old password")
|
|
login = await _login(integration_client, "old password")
|
|
token = login.json()["token"]
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
|
|
resp = await integration_client.patch(
|
|
"/admin/api/password",
|
|
json={"current_password": "not the password", "new_password": "new password"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_update_password_rejects_short_new(
|
|
integration_client: AsyncClient,
|
|
) -> None:
|
|
await _seed_password("old password")
|
|
login = await _login(integration_client, "old password")
|
|
token = login.json()["token"]
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
|
|
resp = await integration_client.patch(
|
|
"/admin/api/password",
|
|
json={"current_password": "old password", "new_password": "x"},
|
|
)
|
|
assert resp.status_code == 400
|