Skip to content

fix(tests): SSO/JWKS/RBAC-gate security tests — stop cross-file autobot_shared stub leak (#13312) - #13324

Merged
mrveiss merged 7 commits into
Dev_new_guifrom
issue-13312-sso
Aug 3, 2026
Merged

mrveiss merged 7 commits into
Dev_new_guifrom
issue-13312-sso

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 2, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

#13312 reported 56 failures + 11 errors from autobot-slm-backend's first-ever CI run. This PR fixes the security-relevant subset — SSO secret migration, SSO auth callback-URL, JWKS/token verification, and the service-management RBAC gate — plus everything else provably cleared by the same root-cause fix.

For each failing test I first checked whether production code or the test was wrong, running each cluster standalone and comparing against git history for the modules under test.

  • migrations/migrate_sso_secrets_test.py, tests/api/test_sso_auth.py, services/jwks_verifier_test.py, tests/test_service_management_gate.py all pass standalone — encryption-at-rest, rollback/commit, the callback-host allowlist, RS256/HS256 JWKS verification, and the SERVICE_MANAGEMENT RBAC gate are all correctly implemented.
  • Running the full autobot-slm-backend suite with -n auto --dist loadscope (the invocation ci.yml used before perf(ci): shard python-suite six ways, recombine coverage, CPU-only torch (#10691) #13314's sharding, still what each shard runs internally) reproduced 33 of the 56 tracked failures deterministically, spanning 11 files, all with the identical symptom: ModuleNotFoundError: ... 'autobot_shared' is not a package or a permission/status object resolving to a MagicMock instead of its real value.
  • Root-caused with a pytest_collectstart probe plugin (not committed) plus a --continue-on-collection-errors serial run: tests/test_rbac_middleware_cache.py and tests/test_rbac_permission_denied_audit.py each replace sys.modules["autobot_shared"] with a bare MagicMock() (no __path__) at module scope, then exec_module() the real rbac_middleware.py. git log -S on that import traces the second dependency it needs (autobot_shared.ssot_constants) to 486eb1900 (refactor(rbac): converge rbac_middleware onto one implementation, wiring in the unused cache accessors (#12925) #13213, 2026-08-01, an ancestor of the python-suite aborts after the first pytest: autobot-slm-backend tests and the 70% coverage gate never run #13300 baseline run) — before that refactor rbac_middleware.py didn't import either module and these two test files collected fine. Neither test file's stub list was updated, so exec_module() raises ModuleNotFoundError before the sys.modules-restore block that follows it — outside any try/finally. Both files fail to collect even in isolation. Because the restore never runs, the broken autobot_shared/fastapi stubs stay in sys.modules for the rest of the pytest process, breaking every later real from autobot_shared.X import Y — this is what took down the other 9 files.
  • pytest-split (the perf(ci): shard python-suite six ways, recombine coverage, CPU-only torch (#10691) #13314 sharding) deselects tests in pytest_collection_modifyitems, which runs after collection/module-import — so the leak reaches every shard's collection regardless of which shard a given test lands in. Rebased onto c4938ec36 (post-sharding) and re-verified with the actual --splits 12 --group N invocation for the two shards that contain every file this PR touches.
  • tests/api/test_auth_logout.py::test_revoke_called_with_positive_ttl is unrelated to the leak: services/token_denylist.py was migrated from get_redis_client(async_client=True) to the safe get_async_redis_client() wrapper in tech-debt(backend): migrate ~47 remaining get_redis_client(async_client=True) call sites to get_async_redis_client() #12807/tech-debt(backend): migrate remaining async-Redis call sites off the legacy API (#12807) #12827, which updated patch targets in six test files but missed this one.
  • Fixing the leak let two more RBAC test files run for the first time and immediately surfaced two more defects, both in the tests, both in files already in this diff: a get_logger stub returning a MagicMock (so logger.error(...) never reached caplog) and a redis.pipeline() stub that auto-specced as async when the real client's pipeline() is synchronous.
  • Reading _build_callback_url line-by-line for the reconstruction/allowlist logic (needed to diagnose the port question) surfaced two real, if low-severity, gaps: urlsplit().port is accessed outside the try that is supposed to turn a bad host into a 400, and X-Forwarded-Proto reaches the returned redirect_uri with no scheme validation despite X-Forwarded-Host on the very next line being treated as hostile. Fixed both with regression tests proven to fail pre-fix.

What Changed

Test-infrastructure fix (the root cause):

  • autobot-slm-backend/tests/test_rbac_middleware_cache.py, tests/test_rbac_permission_denied_audit.py: add the missing autobot_shared.logging_manager / autobot_shared.ssot_constants stubs, wrap the exec_module() call + sys.modules restore in try/finally so a future import added to rbac_middleware.py can't leak stub state again, fix get_logger's stub to be the real logging.getLogger (not a swallowing MagicMock), and fix the fake Redis client's .pipeline() to match the real synchronous-pipeline/async-.execute() shape.

Test-only fix (stale patch target, unrelated to the leak):

  • autobot-slm-backend/tests/api/test_auth_logout.py: patch get_async_redis_client instead of the removed get_redis_client.

Production security fix (autobot-slm-backend/api/sso_auth.py), both on public, pre-auth SSO endpoints, both with new regression tests proven to fail against the pre-fix code:

  • Move the urlsplit().port read inside the existing try/except — it re-parses lazily and raises ValueError for a non-numeric or >65535 port, which previously escaped as an unhandled 500 instead of the intended 400.
  • Reject any X-Forwarded-Proto outside {http, https} before it reaches the returned redirect_uri — it was unvalidated attacker input reaching the response unlike X-Forwarded-Host on the next line.
  • Extracted the repeated log+raise into _reject_callback() to keep the function within the house line-count convention with the two new guards.

Test-only fix (autobot-slm-backend/tests/api/test_sso_auth.py): stub api.security in _load_sso_auth_with_stubs() — its absence meant sso_auth.py's from api.security import create_audit_log real-imported api/security.py, whose route decorators need fastapi.status.HTTP_201_CREATED, present on a full MagicMock fastapi but not on the narrower stub some tests pass. Discovered because the pre-existing test_build_callback_url_invalid_host_rejected has the identical failure mode in isolation.

Verification

Rebased onto origin/Dev_new_gui at c4938ec36 (#13314's 12-way shard) for the sweeps below, then rebased once more onto 93c17474d (#13328, the issue-link gate widening) before the final push — no test-affecting change in that second rebase. All numbers below are from the c4938ec36 base.

Accounting reconciled against a full sweep on the rebased base, before any commit in this PR (-n auto --dist loadscope, unsharded — equivalent to any one shard's internal collection since pytest-split deselects after import): 33 failed, 1462 passed, 10 errors. 33 + the 23 below = 56, matching #13312 exactly.

33 verified-resolved by this PR, enumerated by file:

File Failing before Resolved
tests/api/test_sso_auth.py 5 yes
tests/test_service_management_gate.py 6 yes
services/jwks_verifier_test.py 2 yes
api/code_source_test.py 3 yes (side effect of the leak fix — file untouched)
tests/test_prometheus_registry_endpoint.py 6 yes (side effect — file untouched)
tests/test_api_request_counter.py 4 yes (side effect — file untouched)
tests/api/test_node_code_status_read_derivation_12428.py 1 yes (side effect — file untouched)
tests/api/test_role_migration_extra_vars.py 1 yes (side effect — file untouched)
tests/test_code_sync_topology.py 2 yes (side effect — file untouched)
tests/api/test_nodes_list_timeout_10913.py 2 yes (side effect — file untouched)
tests/api/test_auth_logout.py 1 yes (unrelated stale patch target, fixed separately)

23 tracked in #13312 with no reproducible local failure and no known cause — not touched, not claimed as resolved:

  • migrations/migrate_sso_secrets_test.py — 18. Ran standalone, full-suite unsharded, and sharded (--splits 12 --group 1, the shard this file lands in): passes every time, before and after this PR. git log adf289c60..HEAD on the migration script and its test is empty. I have no working hypothesis for why CI's original run saw 18 failures here and explicitly do not carry one — an earlier draft of this PR blamed a worker-count-dependent pollution reach, which a -n 4 --cov re-run disproved (byte-identical 33/10 failure set): pytest-xdist workers each perform a full collection, so the leak's reach does not vary with worker count, and it isn't the explanation for this cluster either.
  • tests/services/version_checker_test.py — 5. Same: no reproducible failure found in any invocation.

Full local sweep after this PR, same unsharded invocation: 1556 passed, 3 skipped, 1 xfailed, 0 failed, 0 errors.

Sharded verification matching the actual CI gate (--splits 12 --group N --durations-path .test_durations_slm, the exact flags ci.yml's python-shard job uses): every file this PR touches or that the leak fix clears lands in shard 1 or shard 5 of 12. Both shards, post-fix:

shard 1/12: 497 passed  (includes migrate_sso_secrets_test.py, jwks_verifier_test.py, test_auth_logout.py)
shard 5/12: 964 passed, 3 skipped, 1 xfailed  (includes test_sso_auth.py, test_service_management_gate.py,
             test_rbac_middleware_cache.py, test_rbac_permission_denied_audit.py)

Zero failures, zero errors in either shard.

New regression tests, proven to fail against the pre-fix api/sso_auth.py:

FAILED test_build_callback_url_invalid_port_rejected — ValueError: Port out of range 0-65535 (uncaught)
FAILED test_build_callback_url_invalid_scheme_rejected — Failed: DID NOT RAISE (javascript:// accepted)

Both pass against the fixed code, along with the full existing 15-test file (17 total after the additions).

Collection errors, part of #13312's tracked "11 errors" bucket per the two RBAC files: test_rbac_middleware_cache.py and test_rbac_permission_denied_audit.py failed to collect (ModuleNotFoundError) both standalone and in every full-suite run before this PR; both collect and pass fully after. test_redis_service_router.py and main_ensure_local_node_test.py (7 sub-tests) also went from erroring to passing locally as a further side effect of the leak fix — not claimed as part of the tracked 11 since that isn't independently confirmed.

No production code changed except the two _build_callback_url guards in api/sso_auth.py. flake8/isort/black clean on every touched file. No commit trailers.

Refs #13312

Model Used

Sonnet 5

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

mrveiss added 5 commits August 3, 2026 01:54
… autobot_shared/fastapi stub across the whole suite (#13312)

test_rbac_middleware_cache.py and test_rbac_permission_denied_audit.py stub
sys.modules["autobot_shared"] with a bare MagicMock (no __path__) then
exec_module() rbac_middleware.py directly. Neither file stubbed
autobot_shared.logging_manager or autobot_shared.ssot_constants, both of
which rbac_middleware.py imports at module level, so exec_module() always
raised ModuleNotFoundError before the sys.modules restore block ran. Because
that restore lived after the load instead of in a finally, the broken
autobot_shared/fastapi stubs leaked into every test collected afterward for
the rest of the pytest session -- the root cause behind SSO auth, JWKS, and
service-management-gate security tests failing with unrelated
"'autobot_shared' is not a package" / MagicMock-attribute errors despite
their own code being correct.
…he current token_denylist API (#13312)

#12807/#12827 migrated token_denylist.py off the sync-returning
get_redis_client(async_client=True) pattern onto get_async_redis_client(),
the safe wrapper that always awaits its own coroutine. That migration
updated patch targets in six test files but missed this one:
patch.object(_dl_mod, "get_redis_client", ...) raised AttributeError since
the module no longer has that name, so the test never exercised revoke_jti
at all. Production code (services/token_denylist.py) already uses the safe
helper -- no security defect, the assertion just targeted a name that no
longer exists.
…ndencies (#13312)

Two follow-up defects surfaced once the sys.modules leak fix let these files
collect and actually execute for the first time:

- get_logger was stubbed as a MagicMock returning a MagicMock, so
  rbac_middleware.py's module-level `logger = get_logger(__name__)` produced
  a logger whose calls went nowhere -- test_db_failure_logs_error asserted on
  caplog.records for a log line that could structurally never arrive. Stub it
  with the real stdlib logging.getLogger instead (rbac_middleware.py used
  logging.getLogger directly before #13213 switched it to the shared
  wrapper, and the test passed then).
- _make_redis()'s AsyncMock() auto-specced r.pipeline as async too, but
  redis.asyncio.Redis.pipeline() is synchronous -- it returns a Pipeline
  object immediately whose .delete() buffers synchronously and only
  .execute() is awaited. The un-awaited coroutine standing in for the
  pipeline raised AttributeError on .delete(). Give the stub a MagicMock
  pipeline with a sync .delete and an AsyncMock .execute, matching the real
  client's shape.
… 500ing the OAuth callback endpoint (#13312)

Two real defects in _build_callback_url (api/sso_auth.py), both on public,
pre-auth SSO endpoints:

- urlsplit()'s .port property re-parses lazily and raises ValueError for a
  non-numeric or out-of-range (>65535) port. The access happened AFTER the
  try/except that was supposed to turn a malformed callback host into a 400,
  so "localhost:99999" in X-Forwarded-Host escaped as an unhandled 500
  instead. Move the .port read inside the try.
- X-Forwarded-Proto is attacker-controlled exactly like X-Forwarded-Host on
  the next line, but reached the returned redirect_uri with no validation
  at all -- "javascript" as the forwarded proto produced
  "javascript://host/api/auth/sso/callback". Reject anything outside
  {http, https} up front.

Extracted the repeated log+raise into _reject_callback() to keep the
function under the house line-count convention with the two new guards.
Added regression tests for both (proven to fail against the pre-fix code).

Also stub api/security in test_sso_auth.py's _load_sso_auth_with_stubs(): its
absence meant sso_auth.py's `from api.security import create_audit_log` real-
imported api/security.py, whose module-level route decorators need
fastapi.status.HTTP_201_CREATED -- present on a full MagicMock fastapi but
not on the narrower SimpleNamespace stub the host-validation tests pass, so
those tests only worked when an earlier test in the same session happened to
cache a real api.security first. Discovered while adding the new tests
above; same failure mode on the pre-existing invalid-host test in isolation.
@mrveiss
mrveiss merged commit 452d076 into Dev_new_gui Aug 3, 2026
46 of 59 checks passed
@mrveiss
mrveiss deleted the issue-13312-sso branch August 3, 2026 01:30
mrveiss added a commit that referenced this pull request Aug 4, 2026
#13447)

* chore(worktree): claim issue-13361-owners (#13361)

* test(infra): stop nine test modules leaking sys.modules stubs (#13361)

Every owner here mutates sys.modules at import or test scope for its own
benefit and then leaves the mutation live for the rest of the session, so
unrelated modules collected afterwards run against a stand-in instead of
the real thing — the defect family behind #13320, #13324 and #13337.

Removed outright, because the dependency they stood in for is a hard
requirement that is always importable:

  tests/llm_interface_pkg/test_anthropic_provider.py  anthropic, xxhash
  tests/llm_interface_pkg/test_groq_provider.py       groq, xxhash
  tests/knowledge/test_cleanup_endpoint.py            llama_index.*, chromadb, redis
  tests/knowledge/test_facts_dedup.py                 same, plus three lazy-import stubs
  tests/knowledge/test_stats_dedup.py                 same

Bracketed by try/finally, because the registration is needed only while
the module body executes:

  services/npu_pipeline/test_dispatcher.py            shard_planner, dispatcher
  tests/services/test_scheduler_registry.py           services.scheduler_registry
  autobot_shared/jina_parser_test.py                  pop-and-reimport
  autobot_shared/url_safety_test.py                   pop-and-reimport

Seven baseline lines deleted; the two dedup modules were never listed —
they only became visible once test_cleanup_endpoint.py stopped winning
the setdefault race, and the guard failed the run until they were fixed.

Three of the removed stubs rebound RedisError/Redis/asyncio on the REAL
redis module, which no sys.modules guard can see. Two sibling suites were
depending on it: the provider chat_completion tests spent 30s each on the
cross-worker rate limiter's Redis retry budget without it (fixed with an
autouse fixture that takes the limiter's documented Redis-unavailable
path), and reranking_staleness_test.py reached the live client because its
patch named the sync get_redis_client the code does not call.

* style(test-infra): declare the real-load key tuple before its use (#13361)
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