Skip to content

Tighten test hygiene: scoped env mutation, narrower exception assertions - #309

Merged
karoltheguy merged 1 commit into
mainfrom
fix/python-test-hygiene
Aug 3, 2026
Merged

Tighten test hygiene: scoped env mutation, narrower exception assertions#309
karoltheguy merged 1 commit into
mainfrom
fix/python-test-hygiene

Conversation

@karoltheguy

Copy link
Copy Markdown
Owner

Fourth PR in the SonarCloud cleanup series (group 3: Python test hygiene), after #303, #304 and #308. Clears 43 of the 44 findings; the 44th is blocked on an app-side fix and explained below.

Rule Count Status
python:S5778 exception test has multiple invocations 24 Fixed
python:S9073 composite assertion 12 Fixed
python:S8997 global state mutation 5 Fixed, found a real bug
python:S5958 assertion too broad 3 2 fixed, 1 blocked

The one that was a real bug, not hygiene

tests/test_config.py's tearDown deleted three environment variables instead of restoring them:

if "QUADLET_MASTER_KEY" in os.environ:
    del os.environ["QUADLET_MASTER_KEY"]

.github/workflows/tests.yml:22 sets QUADLET_MASTER_KEY for the entire job, so running this TestCase silently unset a variable it never owned. It has not bitten yet only because --dist=loadfile puts each file in its own worker, which masks the contamination.

setUp now snapshots the three variables and tearDown restores the exact prior state, including restoring "was absent" as absent. Demonstrated by running the TestCase in-process with the variable set:

BEFORE   tests=4 failures=0  QUADLET_MASTER_KEY after = None
AFTER    tests=4 failures=0  QUADLET_MASTER_KEY after = 'sentinel_value_from_ci'

Note the rule's own suggested fix is impossible in that file: it is a unittest.TestCase, and monkeypatch is a pytest fixture that cannot be injected into TestCase methods. Those three use patch.dict instead, which restores prior values rather than clearing them. The two findings in test_crypto.py are plain pytest functions and do use monkeypatch; both genuinely leaked, one via a finally that restored DATABASE_PATH but not the env var, the other via a pop on the last line that never runs if an earlier assert fails.

The blocked one

tests/test_crypto.py keeps pytest.raises(Exception) with a comment saying why. services/ssh_manager.py:118 genuinely raises bare Exception on the decrypt-failure path, so there is no more specific type to narrow to. That line is already tracked as python:S112 ("replace this generic exception class with a more specific one") and is scheduled in group 8. Once the app raises a dedicated exception, this test can narrow. Inventing a type here would have made the test pass for the wrong reason.

What the mechanical fixes actually change

S5778: the offending second invocation was always mock construction inside the block, so it could pass because MagicMock() threw rather than the code under test. Hoisted to a local; each block now contains exactly the call under test.

S9073: one split is worth a look. It exposed a decorative half:

assert "(" in teardown_block, "Expected window._quadletLintDetach() to be invoked..."

Standing alone, "this JavaScript contains an open paren" is vacuously true. Replaced with assert "window._quadletLintDetach(" in teardown_block, which implies both original halves and actually tests invocation:

Scenario Old New
function invoked passes passes
merely referenced, never invoked passes fails

Verification

Test hygiene changes can quietly delete coverage, so this was checked by AST rather than by eye:

  • 159 test functions before and after, none removed
  • assert statements 251 to 262, up from splitting composites, never down
  • no exception type broadened: pytest.raises(Exception) went 3 to 1, the remainder being the blocked case
  • -m unit: 939 passed, 3 skipped. Unmarked suite: 47 passed
  • Re-run in random order (-p randomly enabled) to catch any new cross-test contamination: 939 passed
  • The two e2e files cannot run locally and were syntax-checked

Clears 43 of the 44 open SonarCloud test-hygiene findings: 24 python:S5778,
12 python:S9073, 5 python:S8997, 2 of 3 python:S5958.

The S8997 group turned up a real bug rather than a style nit.
tests/test_config.py's tearDown deleted QUADLET_CONFIG_PATH,
QUADLET_MASTER_KEY and DEV_AUTO_LOGIN instead of restoring them.
.github/workflows/tests.yml sets QUADLET_MASTER_KEY for the whole job, so
running this TestCase unset a variable it never owned; only --dist=loadfile
sharding kept it from biting. setUp now snapshots those three and tearDown
restores the exact prior state. Demonstrated by running the TestCase
in-process with the variable set: before, it was None afterwards; after,
it survives intact.

The file is a unittest.TestCase, so the rule's suggested monkeypatch fix is
impossible there (monkeypatch is a pytest fixture and cannot be injected
into TestCase methods). Those three use patch.dict instead, which restores
prior values rather than clearing them. The two in test_crypto.py are plain
pytest functions and do use monkeypatch; both previously leaked, one via a
finally that restored DATABASE_PATH but not the env var, the other via a
pop on the last line that never ran if an earlier assert failed.

S5778: the second throwing invocation was always mock construction inside
the pytest.raises block, so the block could pass because MagicMock() threw.
Hoisted to a local; each block now contains exactly the call under test.

S5958: narrowed pytest.raises(Exception) to HostKeyMismatchError and
HTTPException, confirmed against the raising code. The third is left as-is
and commented: services/ssh_manager.py:118 genuinely raises bare Exception.
Narrowing it needs the app-side fix already tracked as python:S112 on that
exact line, so it is blocked rather than skipped.

S9073: composite assertions split so a failure identifies which half broke.
One split exposed a decorative assertion, `"(" in teardown_block`, which is
vacuously true alone; replaced with `"window._quadletLintDetach(" in
teardown_block`, which implies both halves and actually tests invocation.

Verified no coverage was lost: 159 test functions before and after, assert
count 251 to 262, no test removed, no exception type broadened.
@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

@codacy-production

Copy link
Copy Markdown
Contributor

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@karoltheguy
karoltheguy merged commit 2cfff57 into main Aug 3, 2026
15 checks passed
@karoltheguy
karoltheguy deleted the fix/python-test-hygiene branch August 3, 2026 20:49
karoltheguy added a commit that referenced this pull request Aug 3, 2026
Fixes the quality gate on this PR. Adding DualProtocolCM to both
test_log_level.py and test_session_duration.py pushed new-code duplication
to 14% against a 3% threshold.

The duplication was mostly pre-existing: the two files carried byte-identical
copies of the login mock, the login helper and SettingsMockDB, 75 duplicated
lines each. Touching those lines made them count as new code, which is the
same inherit-the-debt effect seen on PR #304.

Extracting the shared scaffolding fixes the cause rather than the symptom.
The two files drop from 147 and 127 lines to 79 and 56, and shared blocks
fall from 75 lines each to 22, which is now just imports and fixtures.

Follows the existing tests/sudo_permissions.py precedent for a non-test
helper module under tests/. test_sync_engine.py has its own DualProtocolCM
copy, deliberately left alone here since it is modified on PR #309 and
touching it would conflict.

Unit 942 and unmarked 47, both unchanged.
karoltheguy added a commit that referenced this pull request Aug 3, 2026
…art 1) (#310)

Fifth PR in the SonarCloud cleanup series (group 8: Python
maintainability), after #303, #304, #308 and #309. Clears 13 of 19
findings. The 6 `python:S3776` cognitive-complexity refactors are held
back for a second PR, since a control-flow change deserves isolated
review and a revert that does not drag constant extraction with it.

| Rule | Count | Status |
|---|---|---|
| `python:S1192` duplicated literals | 7 | Fixed |
| `python:S108` empty block | 2 | Fixed, exposed two bad test mocks |
| `python:S1066` nested if | 2 | Fixed |
| `python:S112` generic exception | 2 | Fixed, unblocks a finding left
open in #309 |
| `python:S3776` cognitive complexity | 6 | Deferred to part 2 |

## S1192, and a count discrepancy worth knowing

Sonar reports 3 occurrences each of `"/login"` and `"/change-password"`.
The file actually contains **5 of each**, since Sonar's
duplicate-literal count excludes route decorators. All occurrences were
replaced, not just the flagged subset.

Verified by AST rather than by eye: each of the seven literals now
appears exactly once, as its constant definition, and **no other string
literal in the file changed count**. That check is what makes a bulk
find-and-replace across a 1700-line routing module trustworthy.

## S108 found a test that was propping up the outlier

The two `async with db.execute(...): pass` blocks become `await
db.execute(...)`, which is what **every other write site in the
codebase** does. These two were the only exceptions.

That broke two tests, and the tests were wrong. Their hand-rolled
`SettingsMockDB.execute` returned a cursor supporting only `async with`,
while a real `aiosqlite` `execute()` result supports both protocols. The
repo already knows this: `tests/test_sync_engine.py:90` defines
`DualProtocolCM` whose docstring says *"Objects returned by aiosqlite
execute() support both `async with obj` and `await obj`."* Both mocks
now use that same pattern, so the fake stops dictating which idiom
production code may use.

Worth being explicit, since "the change broke tests so I changed the
tests" deserves scrutiny: the mock was asserting an implementation
detail, not behavior. The tests still prove the same thing, that
settings persist and apply live.

## S112, and why these do not subclass SSHCommandError

The two bare `raise Exception` in `ssh_manager.py` become
`ServerConfigurationError` and `KeyDecryptionError`, both subclassing
`Exception` directly.

Not `SSHCommandError`, because that type carries `exit_status` and
`stderr` for a command that actually ran, and
`tests/test_ssh_manager.py:137-138` asserts on exactly those fields.
Both new errors fire while assembling the connection, before anything
reaches the host, so typing them as command failures would hand callers
a null exit status and imply a command failed when none was issued.

Caller audit: all ~41 handlers in the codebase use `except Exception`,
and there are no exact-type comparisons such as `type(e) is Exception`,
so nothing downstream changes. Three new tests cover both types plus
`__cause__` chaining, and they fail against the previous code at import
time.

**Follow-up this enables:** #309 left one `python:S5958` open because
`ssh_manager.py:118` raised bare `Exception`, with a comment saying so.
Once both merge, that comment is stale and the test can narrow to
`KeyDecryptionError`. It could not be done in either PR alone without a
conflict, since `tests/test_crypto.py` is modified in #309.

## S1066

Two nested ifs merged. `and` short-circuits, so `_drop_if_stale()`'s
side effect still fires only when the connection is cached, exactly as
before, and the conditions are not reordered.

**One deliberate extra, not flagged by Sonar:** the structurally
identical re-check inside `get_connection`'s lock was merged too. Fixing
only the flagged one left two adjacent, semantically identical checks
written differently, which is worse than either form. Easy to drop if
you would rather keep the diff strictly to the findings.

## Verification

* `-m unit`: 939 to 942, the three new exception tests, nothing lost
* unmarked suite: 47 passed, unchanged
* re-run in random order to catch cross-test contamination: 942 passed
* AST verification of literal extraction as described above

## Deliberately skipped, with reasons

Three `S3776` findings will not be fixed even in part 2:

* `sync_engine.py:166` `check_quadlets` (complexity 25) and
`sync_engine.py:114` `_fetch_mtimes`. These are the subject of open bug
#283: the function iterates the `quadlets` table, and the only `INSERT
INTO quadlets` anywhere in the repo is in test files, so it is dead in
production. Its uncovered branches are **uncoverable by construction**
while the work set is always empty, meaning no test can protect a
refactor of the most complex function in the group. Whoever fixes #283
rewrites it anyway.
* `scripts/import_servers.py:12`, a one-off script not referenced by
`Dockerfile` or `start.sh`, so not shipped, and not covered.
Restructuring untested, unshipped code to satisfy a metric is risk
without benefit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant