test: address PR #707 reviewer feedback on config path helpers

- _resolve_settings_files() now raises FileNotFoundError when
  NEXTCLOUD_MCP_SETTINGS_FILE points to a missing file, instead of
  silently falling back to defaults (footgun on typos).
- .secrets.toml is now looked for alongside the explicit settings file
  when NEXTCLOUD_MCP_SETTINGS_FILE is set, matching user expectation for
  /etc-style deployments. Unset behaviour (cwd lookup) is unchanged.
- get_token_db_path() drops the redundant os.environ.get() short-circuit;
  TOKEN_STORAGE_DB is already bound through dynaconf because the key is
  declared in _DEFAULTS.
- is_ephemeral_token_db() docstring documents the "must call
  get_token_db_path() first" precondition.
- alembic.ini comment clarifies the ./tokens.db placeholder is cwd-relative
  by design and points readers at the -x database_url escape hatch.
- New tests/unit/test_config_paths.py (12 tests) covering the ephemeral
  tempfile lifecycle, the TOKEN_STORAGE_DB override path, and all six
  _resolve_settings_files() cases including the two new behaviours.

Full unit suite now at 476 passed (464 + 12 new). Ruff + ty clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-14 21:14:42 +02:00
co-authored by Claude Opus 4.6
parent 146b622ebf
commit 512de1f6b0
3 changed files with 193 additions and 15 deletions
+5 -1
View File
@@ -44,7 +44,11 @@ path_separator = os
# Runtime code (migrations.get_alembic_config / alembic/env.get_database_url)
# overrides this with config.get_token_db_path() which defaults to an
# ephemeral per-process tempfile unless TOKEN_STORAGE_DB is set.
# This value only matters for manual `alembic` invocations from the repo root.
# This value only matters for manual `alembic` invocations (e.g.
# `uv run alembic current`). The `./tokens.db` path is intentionally
# resolved relative to the shell's current working directory at the time
# of invocation — run alembic from the repo root for predictable behaviour,
# or pass `-x database_url=sqlite+aiosqlite:///<abs-path>`.
sqlalchemy.url = sqlite+aiosqlite:///./tokens.db
[post_write_hooks]
+29 -13
View File
@@ -113,26 +113,34 @@ def _resolve_settings_files() -> list[str]:
"""Find optional external settings files.
Priority:
1. NEXTCLOUD_MCP_SETTINGS_FILE env var (absolute or relative path)
2. ./settings.toml in cwd (for docker / dev workflows)
3. .secrets.toml alongside whichever settings.toml was found
1. NEXTCLOUD_MCP_SETTINGS_FILE env var (absolute or relative path).
If set but the file does not exist, raise FileNotFoundError —
silently falling back to defaults on a typo would be a footgun.
.secrets.toml is looked for alongside the explicit file.
2. Otherwise ./settings.toml in cwd (for docker / dev workflows),
with .secrets.toml also looked for in cwd.
Returns an empty list if no files are present — that's fine, defaults and
env vars still apply.
Returns an empty list if nothing is configured — that's fine, defaults
and env vars still apply.
"""
files: list[str] = []
explicit = os.environ.get("NEXTCLOUD_MCP_SETTINGS_FILE")
if explicit:
p = Path(explicit)
if p.exists():
if not p.exists():
raise FileNotFoundError(
f"NEXTCLOUD_MCP_SETTINGS_FILE points to a file that does "
f"not exist: {explicit}"
)
files.append(str(p))
secrets = p.parent / ".secrets.toml"
else:
cwd_settings = Path.cwd() / "settings.toml"
if cwd_settings.exists():
files.append(str(cwd_settings))
cwd_secrets = Path.cwd() / ".secrets.toml"
if cwd_secrets.exists():
files.append(str(cwd_secrets))
secrets = Path.cwd() / ".secrets.toml"
if secrets.exists():
files.append(str(secrets))
return files
@@ -198,14 +206,15 @@ def get_token_db_path() -> str:
"""Resolve the token SQLite database path.
Priority:
1. TOKEN_STORAGE_DB env var / dynaconf setting if explicitly set —
docker-compose pins /app/data/tokens.db this way.
1. TOKEN_STORAGE_DB if explicitly set — docker-compose pins
/app/data/tokens.db this way. Read via dynaconf, which picks up
the env var because TOKEN_STORAGE_DB is declared in _DEFAULTS.
2. Otherwise a per-process tempfile under tempfile.gettempdir(),
allocated lazily and deleted at interpreter exit via atexit.
Ephemeral: tokens are wiped on restart, matching the Qdrant
":memory:" default pattern used elsewhere in this project.
"""
explicit = os.environ.get("TOKEN_STORAGE_DB") or _dynaconf.get("TOKEN_STORAGE_DB")
explicit = _dynaconf.get("TOKEN_STORAGE_DB")
if explicit:
return str(explicit)
global _ephemeral_db_path
@@ -228,7 +237,14 @@ def get_token_db_path() -> str:
def is_ephemeral_token_db(path: str) -> bool:
"""Return True if the given path is the process-local ephemeral tempfile."""
"""Return True if the given path is the process-local ephemeral tempfile.
Precondition: `get_token_db_path()` must have been called at least once
in this process to allocate the tempfile. If called before allocation,
this returns False for any input (including the eventual tempfile path),
because there is nothing to compare against yet. In practice every call
site in this repo resolves the path via `get_token_db_path()` first.
"""
return path == _ephemeral_db_path
+158
View File
@@ -0,0 +1,158 @@
"""Tests for config.py path resolution helpers added in PR #707.
Covers:
- get_token_db_path() / is_ephemeral_token_db() — ephemeral tempfile default
with TOKEN_STORAGE_DB override.
- _resolve_settings_files() — optional external settings file discovery,
including the NEXTCLOUD_MCP_SETTINGS_FILE env var and its colocation
semantics for .secrets.toml.
"""
import os
import tempfile
from pathlib import Path
import pytest
import nextcloud_mcp_server.config as cfg
from nextcloud_mcp_server.config import (
_reload_config,
_resolve_settings_files,
get_token_db_path,
is_ephemeral_token_db,
)
@pytest.fixture(autouse=True)
def _reset_ephemeral_state(monkeypatch):
"""Reset module-global ephemeral tempfile state between tests.
get_token_db_path() memoizes its result in a module-level global and
registers an atexit hook for cleanup. Tests need an isolated slate so
assertions about "already allocated" vs "not yet" are meaningful.
"""
old = cfg._ephemeral_db_path
cfg._ephemeral_db_path = None
monkeypatch.delenv("TOKEN_STORAGE_DB", raising=False)
monkeypatch.delenv("NEXTCLOUD_MCP_SETTINGS_FILE", raising=False)
_reload_config()
yield
if cfg._ephemeral_db_path and os.path.exists(cfg._ephemeral_db_path):
os.unlink(cfg._ephemeral_db_path)
cfg._ephemeral_db_path = old
_reload_config()
class TestGetTokenDbPath:
def test_explicit_env_var_returned(self, monkeypatch, tmp_path):
target = tmp_path / "explicit.db"
monkeypatch.setenv("TOKEN_STORAGE_DB", str(target))
_reload_config()
assert get_token_db_path() == str(target)
# No tempfile should have been allocated since we took the explicit
# branch.
assert cfg._ephemeral_db_path is None
def test_ephemeral_tempfile_when_unset(self):
path = get_token_db_path()
assert path.startswith(tempfile.gettempdir())
assert f"nextcloud-mcp-tokens-{os.getpid()}-" in os.path.basename(path)
assert path.endswith(".db")
assert os.path.exists(path)
def test_ephemeral_tempfile_is_memoized(self):
first = get_token_db_path()
second = get_token_db_path()
assert first == second
# Only the memoized file should exist — no stray siblings.
parent = Path(first).parent
matches = list(parent.glob(f"nextcloud-mcp-tokens-{os.getpid()}-*.db"))
assert matches == [Path(first)]
def test_is_ephemeral_token_db_detects_allocated_path(self):
path = get_token_db_path()
assert is_ephemeral_token_db(path) is True
assert is_ephemeral_token_db("/some/other/path") is False
def test_is_ephemeral_token_db_before_allocation(self):
# The autouse fixture reset _ephemeral_db_path to None; do not call
# get_token_db_path() first. Nothing is allocated yet.
assert cfg._ephemeral_db_path is None
assert is_ephemeral_token_db("/any/path") is False
assert is_ephemeral_token_db("") is False
def test_explicit_path_does_not_trigger_tempfile(self, monkeypatch, tmp_path):
"""Regression guard: the explicit branch must short-circuit cleanly."""
monkeypatch.setenv("TOKEN_STORAGE_DB", str(tmp_path / "pinned.db"))
_reload_config()
get_token_db_path()
# Nothing under the tempfile prefix should have been created.
matches = list(
Path(tempfile.gettempdir()).glob(f"nextcloud-mcp-tokens-{os.getpid()}-*.db")
)
assert matches == []
class TestResolveSettingsFiles:
def test_empty_list_when_nothing_present(self, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
assert _resolve_settings_files() == []
def test_picks_up_cwd_settings(self, monkeypatch, tmp_path):
(tmp_path / "settings.toml").write_text("[default]\n")
monkeypatch.chdir(tmp_path)
result = _resolve_settings_files()
assert str(tmp_path / "settings.toml") in result
def test_picks_up_cwd_secrets(self, monkeypatch, tmp_path):
(tmp_path / ".secrets.toml").write_text("[default]\n")
monkeypatch.chdir(tmp_path)
result = _resolve_settings_files()
assert str(tmp_path / ".secrets.toml") in result
def test_explicit_settings_file_included(self, monkeypatch, tmp_path):
explicit = tmp_path / "nested" / "my-settings.toml"
explicit.parent.mkdir()
explicit.write_text("[default]\n")
monkeypatch.setenv("NEXTCLOUD_MCP_SETTINGS_FILE", str(explicit))
# cwd has no settings.toml / .secrets.toml
monkeypatch.chdir(tmp_path)
result = _resolve_settings_files()
assert result == [str(explicit)]
def test_explicit_settings_file_secrets_colocated(self, monkeypatch, tmp_path):
"""PR #707 reviewer feedback: .secrets.toml should live beside
the explicit settings file, not always in cwd."""
config_dir = tmp_path / "etc"
config_dir.mkdir()
explicit = config_dir / "settings.toml"
explicit.write_text("[default]\n")
secrets = config_dir / ".secrets.toml"
secrets.write_text("[default]\n")
monkeypatch.setenv("NEXTCLOUD_MCP_SETTINGS_FILE", str(explicit))
# cwd is elsewhere and deliberately contains *no* secrets file
monkeypatch.chdir(tmp_path)
result = _resolve_settings_files()
assert str(explicit) in result
assert str(secrets) in result
def test_explicit_missing_raises(self, monkeypatch, tmp_path):
"""PR #707 reviewer feedback: missing explicit path must not be
silently ignored — users will think their config is applied when
it isn't."""
missing = tmp_path / "does-not-exist.toml"
monkeypatch.setenv("NEXTCLOUD_MCP_SETTINGS_FILE", str(missing))
monkeypatch.chdir(tmp_path)
with pytest.raises(FileNotFoundError, match="does-not-exist.toml"):
_resolve_settings_files()