fix(auth): treat empty / whitespace-only credentials as absent (closes #35)#66
Conversation
Closes #35. _resolve_credentials() in core/auth.py previously trusted that env / config / keyring values, if present, contained real credentials. Empty strings already fell through (Python truthiness handled them), but whitespace-only values — e.g. `auth: {username: " "}` mid-edit — slipped through and reached login() as (' ', '\t', None), surfacing as a generic DSM 400 that pointed neither at the config nor at the empty values. Fix: new _present_or_none() helper returns the value unchanged when it has any non-whitespace content, otherwise None. Applied at all nine read sites — three credential fields (username, password, device_id) × three storage tiers (env vars, plaintext config file, OS keyring). Meaningful padding is preserved: a real password " pwd " keeps its surrounding spaces. Only purely empty / whitespace-only inputs are filtered. Tests added in tests/core/test_auth.py::TestCredentialResolution: - test_whitespace_config_credentials_fall_through - test_whitespace_env_credentials_fall_through - test_whitespace_keyring_credentials_fall_through - test_empty_string_env_credentials_fall_through (regression) - test_whitespace_env_falls_through_to_valid_keyring - test_valid_credentials_with_internal_padding_preserved 518 tests pass at 96.15% coverage. Ruff and mypy clean.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
cmeans
left a comment
There was a problem hiding this comment.
QA Round 1 — PR #66
Issue #35 acceptance-criteria audit
| AC item | Status | Evidence |
|---|---|---|
_resolve_credentials() validates username and password non-empty before returning; empty values fall through to next strategy |
✅ | _present_or_none() helper at auth.py:30-39; applied at all 9 read sites (env / config / keyring × user / pass / device_id) |
| Unit test for empty string at each level of resolution chain | ✅ | test_empty_string_env_credentials_fall_through (regression coverage); pre-existing tests already cover empty config |
| Whitespace-only handled | ✅ | test_whitespace_config_credentials_fall_through, test_whitespace_env_credentials_fall_through, test_whitespace_keyring_credentials_fall_through |
CHANGELOG ### Fixed entry |
✅ | Present, references #66 |
Helper correctness audit
def _present_or_none(value: str | None) -> str | None:
return value if (value and value.strip()) else NoneIndependent live behavior check:
| Input | Output | Note |
|---|---|---|
None |
None |
absent → absent |
"" |
None |
empty → absent |
" " |
None |
spaces → absent |
"\t", "\n" |
None |
other whitespace → absent |
"alice" |
"alice" |
meaningful → preserved |
" alice " |
" alice " |
padded password → preserved unchanged (not stripped) |
"pwd-with-spaces " |
"pwd-with-spaces " |
trailing padding → preserved unchanged |
The helper returns the original value, not value.strip() — meaningful padding in actual passwords is preserved. The strip is purely for the truthiness check. Correctly tested by test_valid_credentials_with_internal_padding_preserved.
Verification on 048ac50
| Check | Result |
|---|---|
uv run pytest |
518 passed (was 512; +6 new), 94 deselected, 96.15% coverage (gate 95%) |
uv run pytest tests/core/test_auth.py::TestCredentialResolution -v |
10/10 pass — 4 pre-existing + 6 new |
uv run ruff check src/ tests/ scripts/ |
clean |
uv run ruff format --check src/ tests/ scripts/ |
69 files clean |
uv run mypy src/ scripts/ |
clean (29 source files) |
Required CI rollup (lint, typecheck, test 3.11/3.12/3.13, version-sync, validate-server-json) |
all green |
CI observation: vdsm flake
vdsm integration tests is showing FAILURE on this PR — but it's unrelated to the change here. The failing test is test_search_keyword_finds_directory, which creates a directory and waits for DSM's search indexer to pick it up:
INFO Created search target directory: /testshare/Documents/Bambu Studio
INFO search_files attempt 1/6: 0 results found
INFO search_files attempt 2/6: 0 results found
... (6 retries with up to 15s waits) ...
FAILED tests/vdsm/test_vdsm_integration.py::TestSearch::test_search_keyword_finds_directory
The auth flow itself succeeded earlier in the run (INFO Authenticated as 'mcpadmin' (session: MCPSynology_vdsm-7-2-2_68c1a830)), and 46 of 47 vdsm tests passed — only the search-indexer test failed. No plausible connection to _present_or_none() in core/auth.py.
vdsm is not in the required-status-check ruleset and has continue-on-error: true (per #24's intentional setup until vdsm has a track record). Per the project's existing convention, this doesn't block merge. Recommend either:
- A. Re-run
vdsm integration teststo confirm flake (one-click in Actions tab), or - B. Accept as known DSM-search-indexer flakiness (consistent with #23's note that DSM search indexing in vdsm has historically been unreliable).
Verdict
Ready for QA Signoff on the code change itself. No findings on _present_or_none() or its application. CI observation above is for maintainer awareness — not a blocker since vdsm is continue-on-error and the failure is unrelated to this PR's diff. QA Approved remains the maintainer's call.
QA audit — round 1Branch / SHA: `fix/auth-validate-plaintext-credentials` @ `048ac50` ``` Independent helper sanity (uv run python -c ...):_present_or_none(None) = None_present_or_none('') = None_present_or_none(' ') = None_present_or_none('\t') = None_present_or_none('\n') = None_present_or_none('alice') = 'alice'_present_or_none(' alice ') = ' alice ' ← padding preserved_present_or_none('pwd-with-spaces ') = 'pwd-with-spaces '``` vdsm flake: `test_search_keyword_finds_directory` failed after 6 retries. Auth flow succeeded; failure is in DSM's post-auth search indexer. 46/47 vdsm tests passed; the one fail is unrelated to `_present_or_none()` in `core/auth.py`. `vdsm` is `continue-on-error: true` and not in the required-check ruleset, so the merge isn't blocked. Recommend maintainer either re-run vdsm or accept as known indexer flake. Outcome: Ready for QA Signoff. All four issue #35 acceptance criteria met; no findings on the code change. `QA Approved` remains the maintainer's call. |
Fixes the tests/test_integration.py::TestSearch::test_search_keyword_finds_directory flake on vdsm CI. The test was reliably failing on PR #66's CI run after 6 retries (65s budget) because DSM Universal Search hadn't crawled /testshare/Documents/Bambu Studio yet — DSM doesn't index non-indexed shares promptly on a freshly-booted vdsm. Adds two synoindex calls in tests/vdsm/setup_dsm.py after the SSH-driven test data creation: /usr/syno/bin/synoindex -A -d /volume1/testshare/Documents /usr/syno/bin/synoindex -A -d /volume1/testshare/Media `synoindex -A -d <path>` registers a directory subtree with DSM's search index immediately, rather than waiting for the periodic indexer to scan. With the test data indexed at golden-image build time, search calls from the test will reliably find the target without retries. Best-effort: a non-zero return from synoindex logs a warning and continues setup. If a hypothetical DSM build doesn't have the binary at /usr/syno/bin/synoindex, image build still completes and we just fall back to the pre-existing flake (no regression). Modifying setup_dsm.py invalidates the vdsm-workflow's golden-image cache key (keyed on hash of the setup scripts), so the next CI run rebuilds the image with the fix baked in. After that, every cache hit gets the indexed state for free. Verification gate (per playbook): can't run synoindex locally without a vdsm container; the live verification is "watch the next vdsm CI run on this PR or a subsequent one and confirm test_search_keyword_finds_directory passes on the first attempt." If synoindex doesn't behave as expected we'll see another flake and iterate — falling back to the pre-existing flake is a no-regression worst case.
Summary
Closes #35.
_resolve_credentials()incore/auth.pypreviously trusted that env / config / keyring values, if present, contained real credentials. Empty strings already fell through (Python truthiness handled them), but whitespace-only values likeauth: {username: " "}mid-edit slipped through and reachedlogin()as(' ', '\t', None), surfacing as a generic DSM 400 that pointed neither at the config nor at the empty values.Reproducer (verified before fix)
After fix:
# Same input → AuthenticationError("No credentials found. Run 'mcp-synology setup'...")Fix
New helper at the top of
core/auth.py:Applied at all nine read sites — three credential fields (
username,password,device_id) × three storage tiers (env vars, plaintext config file, OS keyring). The truthiness checks downstream (if not username and ...) are unchanged; the helper just normalizes empty / whitespace-only inputs toNoneat the boundary so they fall through the resolution chain like absent values.Meaningful padding is preserved: a real password
" pwd "keeps its surrounding spaces. Only purely empty / whitespace-only inputs are filtered ("".strip() == ""→ falsy → filtered;" alice ".strip() == "alice"→ truthy → preserved as" alice ").Acceptance criteria (per #35)
_resolve_credentials()validatesusernameandpasswordare non-empty before returning. Empty values are treated as "credentials absent" and fall through to the next strategy in the chain.### Fixedentry referencing this PR.Test additions
test_whitespace_config_credentials_fall_throughAuthenticationErrortest_whitespace_env_credentials_fall_throughAuthenticationErrortest_whitespace_keyring_credentials_fall_throughAuthenticationErrortest_empty_string_env_credentials_fall_throughtest_whitespace_env_falls_through_to_valid_keyringtest_valid_credentials_with_internal_padding_preserved" alice "is NOT strippedTest plan
uv run pytest— 518 passed (was 512; added 6 tests), 94 deselected, 96.15% coverageuv run pytest tests/core/test_auth.py::TestCredentialResolution— 10/10 pass (4 existing + 6 new)uv run ruff check src/ tests/ scripts/— cleanuv run ruff format --check src/ tests/ scripts/— cleanuv run mypy src/ scripts/— clean