mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 11:04:36 +00:00
upstream_api_key was added to SECRET_FIELDS, so it was stripped from every blob write — but unlike nsec, nothing migrates it into encrypted storage. A node carrying it only in the DB blob would load it into memory once, rewrite the blob without it, and lose it on the next restart, breaking upstream auth. It is node-scoped config that really belongs on a provider, not a vault secret, and it has no encrypted home yet. Remove it from SECRET_FIELDS so it stays in the blob exactly as before; redaction on read and ignore-on-write in the admin settings endpoint are unchanged. Encrypting it is follow-up work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
9.6 KiB
Python
253 lines
9.6 KiB
Python
import json
|
|
import os
|
|
|
|
import pytest
|
|
from pydantic.v1 import ValidationError
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from sqlmodel import text
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from routstr.core.settings import Settings, SettingsService, settings
|
|
|
|
NSEC_HEX = "1" * 64
|
|
|
|
|
|
async def _read_settings_blob(session: AsyncSession) -> dict:
|
|
"""Return the raw persisted settings JSON (id=1) as a dict."""
|
|
row = await session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore
|
|
return json.loads(row.first()[0])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settings_seed_from_env_and_persist() -> None:
|
|
os.environ["UPSTREAM_BASE_URL"] = "https://api.test/v1"
|
|
os.environ.pop("ONION_URL", None)
|
|
os.environ.pop("ENABLE_ANALYTICS_SHARING", None)
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
settings = await SettingsService.initialize(session)
|
|
|
|
assert settings.upstream_base_url == "https://api.test/v1"
|
|
# ONION_URL may be empty if not discoverable
|
|
assert isinstance(settings.onion_url, str)
|
|
assert settings.enable_analytics_sharing is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settings_db_precedence_over_env() -> None:
|
|
os.environ["UPSTREAM_BASE_URL"] = "https://api.env/v1"
|
|
os.environ["ENABLE_ANALYTICS_SHARING"] = "true"
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
_ = await SettingsService.initialize(session)
|
|
updated = await SettingsService.update(
|
|
{"name": "DBName", "enable_analytics_sharing": False}, session
|
|
)
|
|
assert updated.name == "DBName"
|
|
assert updated.enable_analytics_sharing is False
|
|
|
|
# Change env and re-initialize; DB should still win
|
|
os.environ["NAME"] = "EnvName"
|
|
os.environ["ENABLE_ANALYTICS_SHARING"] = "true"
|
|
again = await SettingsService.initialize(session)
|
|
assert again.name == "DBName"
|
|
assert again.enable_analytics_sharing is False
|
|
|
|
|
|
def test_payout_settings_have_sensible_defaults() -> None:
|
|
s = Settings()
|
|
assert s.min_payout_sat == 210
|
|
assert s.payout_interval_seconds == 900
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field,bad_value",
|
|
[
|
|
("min_payout_sat", 0),
|
|
("min_payout_sat", -1),
|
|
("payout_interval_seconds", 0),
|
|
("payout_interval_seconds", -10),
|
|
],
|
|
)
|
|
def test_payout_settings_reject_invalid_values(field: str, bad_value: int) -> None:
|
|
kwargs: dict[str, object] = {field: bad_value}
|
|
with pytest.raises(ValidationError):
|
|
Settings(**kwargs) # type: ignore[arg-type]
|
|
|
|
|
|
def test_payout_settings_accept_custom_positive_values() -> None:
|
|
s = Settings(min_payout_sat=500, payout_interval_seconds=60)
|
|
assert s.min_payout_sat == 500
|
|
assert s.payout_interval_seconds == 60
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_payout_settings_persist_via_settings_service() -> None:
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
await SettingsService.initialize(session)
|
|
updated = await SettingsService.update(
|
|
{"min_payout_sat": 1000, "payout_interval_seconds": 300}, session
|
|
)
|
|
assert updated.min_payout_sat == 1000
|
|
assert updated.payout_interval_seconds == 300
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_payout_settings_update_rejects_invalid() -> None:
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
await SettingsService.initialize(session)
|
|
with pytest.raises(ValidationError):
|
|
await SettingsService.update({"min_payout_sat": 0}, session)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settings_initialize_discards_unknown_keys() -> None:
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
_ = await SettingsService.initialize(session)
|
|
|
|
# Simulate older persisted key name and an unknown key.
|
|
await session.exec( # type: ignore
|
|
text(
|
|
"UPDATE settings SET data = :data WHERE id = 1"
|
|
).bindparams(
|
|
data='{"name":"LegacyNode","nostr_analytics_enabled":false,"unknown_key":123}'
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
reloaded = await SettingsService.initialize(session)
|
|
assert reloaded.name == "LegacyNode"
|
|
assert reloaded.enable_analytics_sharing is True
|
|
|
|
row = await session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore
|
|
stored_data = row.first()[0]
|
|
assert '"enable_analytics_sharing": true' in stored_data
|
|
assert "nostr_analytics_enabled" not in stored_data
|
|
assert "unknown_key" not in stored_data
|
|
|
|
|
|
# ── Secret fields are never written to the settings blob (issue #553) ────────
|
|
|
|
|
|
def test_settings_model_drops_admin_password_field() -> None:
|
|
# admin_password now lives only as a one-way hash in the Secret store; it is
|
|
# no longer a settings field at all.
|
|
assert "admin_password" not in Settings.__fields__
|
|
# nsec remains a runtime value held in memory; upstream_api_key is ordinary
|
|
# config that still lives in the persisted blob.
|
|
assert "nsec" in Settings.__fields__
|
|
assert "upstream_api_key" in Settings.__fields__
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_secret_fields_kept_in_memory_but_not_persisted(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("NSEC", NSEC_HEX)
|
|
# Reset the live globals so monkeypatch reverts them after the test.
|
|
monkeypatch.setattr(settings, "nsec", "")
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
s = await SettingsService.initialize(session)
|
|
|
|
# Runtime consumers still see the live secret value.
|
|
assert s.nsec == NSEC_HEX
|
|
|
|
# ...but it is never written to the settings blob.
|
|
blob = await _read_settings_blob(session)
|
|
assert "nsec" not in blob
|
|
assert "admin_password" not in blob
|
|
# Non-secret derived/public values are still persisted.
|
|
assert blob["npub"] == s.npub
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upstream_api_key_survives_persistence(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# upstream_api_key is provider-scoped config, not a vault secret: it has no
|
|
# encrypted home yet, so it must stay in the settings blob. Stripping it
|
|
# would load it once, rewrite the blob without it, and lose it on the next
|
|
# restart. Guard the on-disk survival path: blob-only value, no env.
|
|
monkeypatch.delenv("UPSTREAM_API_KEY", raising=False)
|
|
monkeypatch.setattr(settings, "upstream_api_key", "")
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
await SettingsService.initialize(session)
|
|
await session.exec( # type: ignore
|
|
text("UPDATE settings SET data = :d WHERE id = 1").bindparams(
|
|
d=json.dumps({"name": "LegacyNode", "upstream_api_key": "sk-only-in-db"})
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
# A reload must not drop the key from the blob...
|
|
await SettingsService.initialize(session)
|
|
blob = await _read_settings_blob(session)
|
|
assert blob["upstream_api_key"] == "sk-only-in-db"
|
|
# ...and it stays live for the proxy hot path.
|
|
assert settings.upstream_api_key == "sk-only-in-db"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_blob_secrets_are_stripped_on_initialize(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("NSEC", raising=False)
|
|
monkeypatch.delenv("UPSTREAM_API_KEY", raising=False)
|
|
monkeypatch.delenv("ADMIN_PASSWORD", raising=False)
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
await SettingsService.initialize(session)
|
|
|
|
# Simulate a legacy row that still carries plaintext secrets in the blob.
|
|
await session.exec( # type: ignore
|
|
text("UPDATE settings SET data = :d WHERE id = 1").bindparams(
|
|
d=json.dumps(
|
|
{
|
|
"name": "LegacyNode",
|
|
"admin_password": "pw",
|
|
"nsec": NSEC_HEX,
|
|
"upstream_api_key": "sk-legacy",
|
|
}
|
|
)
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
await SettingsService.initialize(session)
|
|
|
|
blob = await _read_settings_blob(session)
|
|
assert "admin_password" not in blob
|
|
assert "nsec" not in blob
|
|
# Non-secret values survive the migration, including upstream_api_key,
|
|
# which is not vaulted yet and so must stay in the blob.
|
|
assert blob["name"] == "LegacyNode"
|
|
assert blob["upstream_api_key"] == "sk-legacy"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_does_not_persist_secret_fields(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("NSEC", NSEC_HEX)
|
|
monkeypatch.setattr(settings, "nsec", "")
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
await SettingsService.initialize(session)
|
|
await SettingsService.update({"name": "Updated"}, session)
|
|
|
|
blob = await _read_settings_blob(session)
|
|
assert blob["name"] == "Updated"
|
|
assert "nsec" not in blob
|
|
assert "admin_password" not in blob
|