mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Add a first-class `deepseek` upstream provider that needs only an API key; the base URL is fixed to https://api.deepseek.com. DEEPSEEK_API_KEY seeds it on startup. Models come from DeepSeek's own /models and are priced from a peak-rate table in routstr/upstream/deepseek.py. A listed model the table misses is imported disabled instead of taking a litellm/OpenRouter price, via a new GenericUpstreamProvider.use_fallback_pricing switch (default True, so other providers are unchanged). The node bills one flat price per model, so the table holds the peak rate and never bills below DeepSeek's cost. Thinking-mode reasoning_content is forwarded unchanged: DeepSeek requires it on requests that carry tools and ignores it otherwise. Remove the temporary DeepSeek V4 pricing shim and its startup call. The pinned litellm 1.101.2 already ships every key it filled.
484 lines
19 KiB
Python
484 lines
19 KiB
Python
"""Tests for ``bootstrap_secrets`` — moving node secrets into the Secret store.
|
|
|
|
Specifies the per-secret bootstrap that runs at startup (issue #553). For both
|
|
the admin password and the nsec it follows the same three branches: use the
|
|
column if already set, otherwise migrate any legacy plaintext (env first, then
|
|
the old settings blob), otherwise — admin password only — generate and log one.
|
|
A column written under a different ROUTSTR_SECRET_KEY fails fast rather than
|
|
silently corrupting state.
|
|
"""
|
|
|
|
import json
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Any, AsyncGenerator
|
|
|
|
import pytest
|
|
from sqlmodel import text
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from routstr.core import vault
|
|
from routstr.core.db import NsecState, get_secret, set_nsec
|
|
from routstr.core.settings import (
|
|
SettingsService,
|
|
bootstrap_secrets,
|
|
derive_npub_from_nsec,
|
|
settings,
|
|
)
|
|
|
|
# Valid Fernet keys; must match the suite default in tests/conftest.py.
|
|
TEST_SECRET_KEY = "l_Tkp-7xmjcQ-IFhr6qhILrU8HPRbEmYMrfSbo_5srU="
|
|
TEST_SECRET_KEY_ALT = "_Teyrky_iToeDK51Tj1FsI9MJ340_cqKGmeher-a7MQ="
|
|
|
|
NSEC_HEX = "1" * 64
|
|
# A different key, standing in for a stale value left behind in env/blob after
|
|
# the vault has taken ownership of the real one.
|
|
STALE_NSEC_HEX = "2" * 64
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_secret_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""No ambient legacy secrets, and a known in-memory settings baseline."""
|
|
monkeypatch.delenv("ADMIN_PASSWORD", raising=False)
|
|
monkeypatch.delenv("NSEC", raising=False)
|
|
monkeypatch.setenv("ROUTSTR_SECRET_KEY", TEST_SECRET_KEY)
|
|
monkeypatch.setattr(settings, "nsec", "")
|
|
monkeypatch.setattr(settings, "npub", "")
|
|
monkeypatch.setattr(settings, "http_url", "")
|
|
|
|
|
|
async def _create_settings_blob(session: AsyncSession, data: dict) -> None:
|
|
await session.exec( # type: ignore
|
|
text(
|
|
"CREATE TABLE IF NOT EXISTS settings "
|
|
"(id INTEGER PRIMARY KEY, data TEXT NOT NULL, "
|
|
"updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
|
|
)
|
|
)
|
|
await session.exec( # type: ignore
|
|
text("INSERT INTO settings (id, data) VALUES (1, :data)").bindparams(
|
|
data=json.dumps(data)
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
# --- admin password --------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generates_admin_password_when_none(
|
|
clean_secret_env: None, integration_session: AsyncSession
|
|
) -> None:
|
|
await bootstrap_secrets(integration_session)
|
|
secret = await get_secret(integration_session)
|
|
assert secret.admin_password_hash is not None
|
|
assert secret.admin_password_hash.startswith("scrypt:")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_password_generation_is_idempotent(
|
|
clean_secret_env: None, integration_session: AsyncSession
|
|
) -> None:
|
|
await bootstrap_secrets(integration_session)
|
|
first = (await get_secret(integration_session)).admin_password_hash
|
|
await bootstrap_secrets(integration_session)
|
|
second = (await get_secret(integration_session)).admin_password_hash
|
|
assert first is not None and first == second
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hashes_legacy_admin_password_from_env(
|
|
clean_secret_env: None,
|
|
integration_session: AsyncSession,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("ADMIN_PASSWORD", "hunter2")
|
|
await bootstrap_secrets(integration_session)
|
|
secret = await get_secret(integration_session)
|
|
assert secret.admin_password_hash is not None
|
|
assert vault.verify_password("hunter2", secret.admin_password_hash) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hashes_legacy_admin_password_from_blob(
|
|
clean_secret_env: None, integration_session: AsyncSession
|
|
) -> None:
|
|
# No ADMIN_PASSWORD in env, but the old settings blob carries one.
|
|
await _create_settings_blob(integration_session, {"admin_password": "blobpw"})
|
|
await bootstrap_secrets(integration_session)
|
|
secret = await get_secret(integration_session)
|
|
assert vault.verify_password("blobpw", secret.admin_password_hash or "") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_password_race_adopts_winner_without_clobber(
|
|
clean_secret_env: None,
|
|
integration_engine: Any,
|
|
integration_session: AsyncSession,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
# Two workers boot against one shared DB and both read a null admin password.
|
|
# The first to commit "wins" and shows the operator its generated password. A
|
|
# worker that read null but lost the race must NOT overwrite the winner's hash
|
|
# (which the operator may already be logging in with) and must NOT print a
|
|
# second password that will never work.
|
|
#
|
|
# The race window is forced deterministically: a hook fires inside bootstrap's
|
|
# generate branch (so it only runs once this worker has committed to
|
|
# generating) and commits the winner's password on a separate connection
|
|
# before this worker writes its own.
|
|
import sqlite3
|
|
|
|
from routstr.core import settings as settings_mod
|
|
|
|
db_file = integration_engine.url.database
|
|
winner_hash = vault.hash_password("winner-password-123")
|
|
real_token = settings_mod.secrets.token_urlsafe
|
|
|
|
def commit_winner_then_generate(nbytes: int) -> str:
|
|
conn = sqlite3.connect(db_file)
|
|
conn.execute(
|
|
"UPDATE secrets SET admin_password_hash = ? WHERE id = 1", (winner_hash,)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return real_token(nbytes)
|
|
|
|
monkeypatch.setattr(
|
|
settings_mod.secrets, "token_urlsafe", commit_winner_then_generate
|
|
)
|
|
|
|
await get_secret(integration_session) # row exists, password still null
|
|
capsys.readouterr() # drop anything emitted before the race resolves
|
|
await bootstrap_secrets(integration_session)
|
|
|
|
secret = await get_secret(integration_session)
|
|
assert secret.admin_password_hash is not None
|
|
# The winner's password survives and still verifies — no clobber.
|
|
assert vault.verify_password("winner-password-123", secret.admin_password_hash)
|
|
# The losing worker stayed silent — no second generated password was leaked.
|
|
assert "generated a temporary" not in capsys.readouterr().out
|
|
|
|
|
|
# --- nsec ------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_encrypts_legacy_nsec_from_env_and_derives_npub(
|
|
clean_secret_env: None,
|
|
integration_session: AsyncSession,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("NSEC", NSEC_HEX)
|
|
await bootstrap_secrets(integration_session)
|
|
secret = await get_secret(integration_session)
|
|
assert secret.encrypted_nsec is not None
|
|
assert vault.is_encrypted(secret.encrypted_nsec) is True
|
|
assert vault.decrypt(secret.encrypted_nsec) == NSEC_HEX
|
|
# In-memory runtime value is the decrypted nsec, and npub is derived from it.
|
|
assert settings.nsec == NSEC_HEX
|
|
assert settings.npub == derive_npub_from_nsec(NSEC_HEX)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decrypts_existing_nsec_column(
|
|
clean_secret_env: None, integration_session: AsyncSession
|
|
) -> None:
|
|
secret = await get_secret(integration_session)
|
|
secret.encrypted_nsec = vault.encrypt(NSEC_HEX)
|
|
secret.nsec_state = NsecState.encrypted
|
|
integration_session.add(secret)
|
|
await integration_session.commit()
|
|
stored = secret.encrypted_nsec
|
|
|
|
await bootstrap_secrets(integration_session)
|
|
reloaded = await get_secret(integration_session)
|
|
assert settings.nsec == NSEC_HEX
|
|
# The column is reused, not re-encrypted.
|
|
assert reloaded.encrypted_nsec == stored
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fail_fast_when_nsec_encrypted_with_different_key(
|
|
clean_secret_env: None,
|
|
integration_session: AsyncSession,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# Encrypt the column under the alternate key, then bootstrap under the
|
|
# suite key -> the value cannot be decrypted -> clear startup failure.
|
|
monkeypatch.setenv("ROUTSTR_SECRET_KEY", TEST_SECRET_KEY_ALT)
|
|
secret = await get_secret(integration_session)
|
|
secret.encrypted_nsec = vault.encrypt(NSEC_HEX)
|
|
secret.nsec_state = NsecState.encrypted
|
|
integration_session.add(secret)
|
|
await integration_session.commit()
|
|
|
|
monkeypatch.setenv("ROUTSTR_SECRET_KEY", TEST_SECRET_KEY)
|
|
with pytest.raises(RuntimeError, match="ROUTSTR_SECRET_KEY"):
|
|
await bootstrap_secrets(integration_session)
|
|
|
|
|
|
# --- encryption is mandatory, key custody is not: upgrade without a key --------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_nsec_without_secret_key_generates_and_encrypts(
|
|
clean_secret_env: None,
|
|
integration_session: AsyncSession,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
# A node upgrading with a legacy plaintext NSEC but no ROUTSTR_SECRET_KEY must
|
|
# NOT break. Encryption at rest stays mandatory (the nsec is never persisted
|
|
# in plaintext), but the key custody is flexible: bootstrap generates a master
|
|
# key, persists it to the key file, warns loudly, and encrypts the identity —
|
|
# so the node keeps running instead of refusing to boot.
|
|
monkeypatch.delenv("ROUTSTR_SECRET_KEY", raising=False)
|
|
key_file = tmp_path / "routstr_secret.key"
|
|
monkeypatch.setenv("ROUTSTR_SECRET_KEY_FILE", str(key_file))
|
|
monkeypatch.setenv("NSEC", NSEC_HEX)
|
|
|
|
await bootstrap_secrets(integration_session)
|
|
|
|
# A master key was generated and persisted...
|
|
assert key_file.exists()
|
|
# ...the nsec is encrypted at rest under it, never stored in plaintext...
|
|
secret = await get_secret(integration_session)
|
|
assert secret.encrypted_nsec is not None
|
|
assert vault.is_encrypted(secret.encrypted_nsec) is True
|
|
assert vault.decrypt(secret.encrypted_nsec) == NSEC_HEX
|
|
assert secret.nsec_state == NsecState.encrypted
|
|
# ...the node holds the live identity (npub derived from it)...
|
|
assert settings.nsec == NSEC_HEX
|
|
assert settings.npub == derive_npub_from_nsec(NSEC_HEX)
|
|
# ...and the operator is loudly told a key was generated and must be backed up
|
|
# (path shown, but never the key value) so an upgrade cannot silently create
|
|
# an unbacked key nor leak the key into captured stdout / aggregated logs.
|
|
out = capsys.readouterr().out
|
|
assert str(key_file) in out
|
|
assert key_file.read_text().strip() not in out
|
|
assert "BACK UP" in out.upper()
|
|
|
|
|
|
# --- boot ordering: rescue legacy blob secrets before they are stripped ----
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blob_only_nsec_is_migrated_before_blob_is_stripped(
|
|
clean_secret_env: None, integration_session: AsyncSession
|
|
) -> None:
|
|
# Legacy node whose nsec lives ONLY in the settings blob (never in env).
|
|
# bootstrap_secrets must run *before* SettingsService.initialize strips the
|
|
# blob, or the only copy of the secret would be lost.
|
|
await _create_settings_blob(
|
|
integration_session, {"nsec": NSEC_HEX, "name": "LegacyNode"}
|
|
)
|
|
|
|
await bootstrap_secrets(integration_session)
|
|
await SettingsService.initialize(integration_session)
|
|
|
|
secret = await get_secret(integration_session)
|
|
# The plaintext nsec has been moved into the encrypted Secret store...
|
|
assert secret.encrypted_nsec is not None
|
|
assert vault.decrypt(secret.encrypted_nsec) == NSEC_HEX
|
|
assert settings.nsec == NSEC_HEX
|
|
# ...and stripped from the persisted settings blob.
|
|
row = await integration_session.exec( # type: ignore
|
|
text("SELECT data FROM settings WHERE id = 1")
|
|
)
|
|
blob = json.loads(row.first()[0])
|
|
assert "nsec" not in blob
|
|
assert blob["name"] == "LegacyNode"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_does_not_clobber_store_only_nsec(
|
|
clean_secret_env: None, integration_session: AsyncSession
|
|
) -> None:
|
|
# Steady state after migration: the nsec lives ONLY in the encrypted Secret
|
|
# store (NSEC removed from env, blob already stripped on a previous boot).
|
|
# bootstrap decrypts it into memory; initialize then re-derives settings from
|
|
# the secret-free blob and must NOT wipe the live nsec back to empty (or the
|
|
# node would silently stop signing Nostr announcements).
|
|
await _create_settings_blob(integration_session, {"name": "LegacyNode"})
|
|
secret = await get_secret(integration_session)
|
|
secret.encrypted_nsec = vault.encrypt(NSEC_HEX)
|
|
secret.nsec_state = NsecState.encrypted
|
|
integration_session.add(secret)
|
|
await integration_session.commit()
|
|
|
|
await bootstrap_secrets(integration_session)
|
|
assert settings.nsec == NSEC_HEX # bootstrap decrypted it into memory
|
|
|
|
await SettingsService.initialize(integration_session)
|
|
# The live secret survives initialize even though no env/blob carries it...
|
|
assert settings.nsec == NSEC_HEX
|
|
# ...and is still never written back to the persisted blob.
|
|
row = await integration_session.exec( # type: ignore
|
|
text("SELECT data FROM settings WHERE id = 1")
|
|
)
|
|
assert "nsec" not in json.loads(row.first()[0])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_env_nsec_does_not_override_vault_nsec(
|
|
clean_secret_env: None,
|
|
integration_session: AsyncSession,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# The vault owns the nsec, but a stale NSEC (e.g. the operator rotated the
|
|
# key in the UI yet left the old value in .env) is still in the environment.
|
|
# bootstrap decrypts the store value; initialize must NOT let the stale env
|
|
# value clobber it, or a restart silently reverts to the old identity.
|
|
await set_nsec(integration_session, NSEC_HEX)
|
|
|
|
monkeypatch.setenv("NSEC", STALE_NSEC_HEX)
|
|
await _create_settings_blob(integration_session, {"name": "LegacyNode"})
|
|
|
|
await bootstrap_secrets(integration_session)
|
|
await SettingsService.initialize(integration_session)
|
|
|
|
# The vault value wins; the stale env value is ignored.
|
|
assert settings.nsec == NSEC_HEX
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_env_nsec_does_not_split_npub_from_vault_nsec(
|
|
clean_secret_env: None,
|
|
integration_session: AsyncSession,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# As above, the vault owns the nsec while a stale NSEC lingers in env. The
|
|
# private key correctly comes from the vault, but the npub must too: if
|
|
# initialize derives the public key from the stale env nsec, the node ends up
|
|
# with a private key from the vault and a public key from the old env value,
|
|
# and anything reading settings.npub announces the wrong Nostr identity.
|
|
expected_npub = derive_npub_from_nsec(NSEC_HEX)
|
|
stale_npub = derive_npub_from_nsec(STALE_NSEC_HEX)
|
|
assert expected_npub and stale_npub and expected_npub != stale_npub # guard
|
|
|
|
await set_nsec(integration_session, NSEC_HEX)
|
|
|
|
monkeypatch.setenv("NSEC", STALE_NSEC_HEX)
|
|
await _create_settings_blob(integration_session, {"name": "LegacyNode"})
|
|
|
|
await bootstrap_secrets(integration_session)
|
|
await SettingsService.initialize(integration_session)
|
|
|
|
assert settings.nsec == NSEC_HEX
|
|
assert settings.npub == expected_npub
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleared_nsec_stays_cleared_across_reboot(
|
|
clean_secret_env: None,
|
|
integration_session: AsyncSession,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# An identity was imported from env, then the operator cleared it via the
|
|
# admin API. The old NSEC is still in env. On the NEXT PROCESS the cleared
|
|
# identity must stay cleared, not get resurrected from the stale env value.
|
|
monkeypatch.setenv("NSEC", NSEC_HEX)
|
|
await bootstrap_secrets(integration_session)
|
|
assert settings.nsec == NSEC_HEX
|
|
|
|
# Clear via the admin path (store empty, vault owns it).
|
|
await set_nsec(integration_session, "")
|
|
|
|
# Simulate a fresh process rather than pre-clearing the live singleton: the
|
|
# pydantic settings global reloads the (still-stale) NSEC from env and derives
|
|
# its npub, which is exactly the in-memory state a new boot starts from before
|
|
# bootstrap runs. The cleared store must win over this stale live value.
|
|
monkeypatch.setattr(settings, "nsec", NSEC_HEX)
|
|
monkeypatch.setattr(settings, "npub", derive_npub_from_nsec(NSEC_HEX))
|
|
|
|
await bootstrap_secrets(integration_session)
|
|
|
|
reloaded = await get_secret(integration_session)
|
|
assert reloaded.nsec_state == NsecState.cleared
|
|
assert reloaded.encrypted_nsec is None # not re-imported
|
|
assert settings.nsec == "" # stays cleared
|
|
assert settings.npub == "" # and no derived public identity survives
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_keeps_npub_matching_store_only_nsec(
|
|
clean_secret_env: None, integration_session: AsyncSession
|
|
) -> None:
|
|
# Steady state with mandatory encryption: the nsec lives ONLY in the
|
|
# encrypted Secret store (env carries no NSEC) and the blob has no npub.
|
|
# bootstrap decrypts the nsec and derives the npub into memory; initialize
|
|
# then re-derives settings from the npub-less blob and must NOT wipe the npub
|
|
# back to empty, or the node holds a private key with no matching public key
|
|
# and silently stops announcing a usable Nostr identity.
|
|
expected_npub = derive_npub_from_nsec(NSEC_HEX)
|
|
assert expected_npub # guard: the test key must yield a real npub
|
|
|
|
await _create_settings_blob(integration_session, {"name": "LegacyNode"})
|
|
secret = await get_secret(integration_session)
|
|
secret.encrypted_nsec = vault.encrypt(NSEC_HEX)
|
|
secret.nsec_state = NsecState.encrypted
|
|
integration_session.add(secret)
|
|
await integration_session.commit()
|
|
|
|
await bootstrap_secrets(integration_session)
|
|
assert settings.npub == expected_npub # bootstrap derived it
|
|
|
|
await SettingsService.initialize(integration_session)
|
|
# The npub still matches the live nsec...
|
|
assert settings.nsec == NSEC_HEX
|
|
assert settings.npub == expected_npub
|
|
# ...and is persisted to the blob (it is public, not a stripped secret).
|
|
row = await integration_session.exec( # type: ignore
|
|
text("SELECT data FROM settings WHERE id = 1")
|
|
)
|
|
assert json.loads(row.first()[0])["npub"] == expected_npub
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_startup_runs_bootstrap_before_settings_initialize(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# The two tests above prove the migration outcome *given* the call order;
|
|
# they hardcode that order themselves. This one guards the order at its real
|
|
# call site — the application lifespan — so a reorder in main.py (which would
|
|
# strip a blob-only secret before bootstrap could rescue it) is caught.
|
|
import routstr.core.main as main
|
|
|
|
order: list[str] = []
|
|
|
|
class _Abort(Exception):
|
|
pass
|
|
|
|
@asynccontextmanager
|
|
async def fake_create_session() -> AsyncGenerator[None, None]:
|
|
yield None
|
|
|
|
async def fake_bootstrap(session: Any) -> None:
|
|
order.append("bootstrap")
|
|
|
|
async def fake_initialize(session: Any) -> None:
|
|
order.append("initialize")
|
|
# Stop startup here, before the background-task fan-out (prices, nostr,
|
|
# upstreams) that we don't want to run in a unit test.
|
|
raise _Abort()
|
|
|
|
async def noop_init_db() -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(main, "configure_litellm", lambda: None)
|
|
monkeypatch.setattr(main, "run_migrations", lambda: None)
|
|
monkeypatch.setattr(main, "init_db", noop_init_db)
|
|
monkeypatch.setattr(main, "create_session", fake_create_session)
|
|
monkeypatch.setattr(main, "bootstrap_secrets", fake_bootstrap)
|
|
monkeypatch.setattr(main.SettingsService, "initialize", fake_initialize)
|
|
|
|
with pytest.raises(_Abort):
|
|
async with main.lifespan(main.app):
|
|
pass
|
|
|
|
assert order == ["bootstrap", "initialize"]
|