Files
pulse/scripts/tests/test_oidc_session_docs.py
T
pulse-triage[bot] 7ab21e217e Explain OIDC session and access-removal limits truthfully
Separate browser-cookie lifetime, sliding server sessions and request-driven token refresh. Remove the false provider-change revocation promise, preserve administrator access and document the existing targeted user-removal boundary and private diagnosis.

Contract-Neutral: Documentation and source-consistency tests only; no authentication, session, authorisation or deployment contract changes.
Change-source: pulse-maintainer
2026-10-07 22:51:30 +01:00

116 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Keep OIDC help honest about existing session and revocation boundaries.
Documentation/source consistency only: this does not exercise an IdP, browser
session, token exchange or installed access removal.
"""
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[2]
GUIDE = ROOT / "docs/OIDC.md"
def guide_section(start, end):
text = GUIDE.read_text(encoding="utf-8")
return " ".join(text.split(start, 1)[1].split(end, 1)[0].split())
class OIDCSessionDocsTest(unittest.TestCase):
def test_cookie_server_and_idp_lifetimes_are_distinct(self):
text = guide_section("### Sessions and `offline_access`", "#### Changing or removing access")
for expected in (
"different lifetimes", "does not guarantee a 30- or 90-day",
"actually issue a refresh token", "SSO can work without",
"24-hour lifetime", "sliding expiry", "not extend the browser cookie",
"request-driven, not continuous polling", "before refresh completes",
):
self.assertIn(expected, text)
def test_token_exchange_failure_is_not_confused_with_missing_provider(self):
text = guide_section("### Sessions and `offline_access`", "#### Changing or removing access")
for expected in (
"token exchange", "invalidates that Pulse session",
"Failure to initialise the provider", "no matching enabled provider",
"skips refresh", "changed issuer/client ID", "encrypted when persisted",
"never publish session files",
):
self.assertIn(expected, text)
self.assertNotIn("automatically invalidated", text)
def test_login_claims_and_provider_changes_do_not_promise_revocation(self):
text = guide_section("#### Changing or removing access", "## 📚 Provider Examples")
for expected in (
"every non-empty restriction must pass", "applied at login",
"does **not** re-evaluate", "existing Pulse session or role assignments",
"Disabling or deleting a provider", "must not be treated as revoking",
"independent administrator login", "Block future sign-ins",
"Pro RBAC", "RBAC.md#removing-user-access", "provider-scoped identity",
"active Pulse sessions", "later authorised SSO login can recreate",
"not proof that an old session lost access", "targeted user removal is unavailable",
"network or authenticated reverse-proxy access boundary",
"blocking only the IdP login is not enough", "Do not disable Pulse authentication",
):
self.assertIn(expected, text)
def test_diagnosis_does_not_request_debug_restart_or_secrets(self):
text = guide_section("### Safe diagnosis", "\0")
for expected in (
"existing login error", "claim names locally", "not paste a full IdP response",
"Do not enable Debug or restart", "locally reviewed, redacted error",
"client secrets", "authorization codes", "ID/access/refresh tokens",
"full callback query strings", "HAR exports", "Copy as cURL",
"Never disable TLS verification or access restrictions",
):
self.assertIn(expected, text)
self.assertNotIn("```", text)
self.assertNotIn("LOG_LEVEL=debug", GUIDE.read_text())
def test_session_and_refresh_claims_match_source(self):
router = (ROOT / "internal/api/router.go").read_text()
session = router.split("func (r *Router) establishOIDCSession(", 1)[1].split(
"// handleLogin", 1
)[0]
self.assertIn("24*time.Hour", session)
self.assertEqual(session.count("MaxAge: 86400") + session.count("MaxAge: 86400"), 2)
store = (ROOT / "internal/api/session_store.go").read_text()
sliding = store.split("func (s *SessionStore) ValidateAndExtendSession(", 1)[1]
self.assertIn("now.After(session.ExpiresAt)", sliding)
self.assertIn("session.ExpiresAt = now.Add(session.OriginalDuration)", sliding)
auth = (ROOT / "internal/api/auth.go").read_text()
self.assertIn("go refreshOIDCSessionTokens(cfg, cookie.Value, session)", auth)
refresh = auth.split("func refreshOIDCSessionTokens(", 1)[1]
self.assertIn("if oidcCfg == nil", refresh)
self.assertIn("if err != nil", refresh)
self.assertIn("GetSessionStore().InvalidateSession(sessionToken)", refresh)
self.assertIn("GetSessionStore().UpdateOIDCTokens", refresh)
self.assertNotIn("resolveGroupRoles", refresh)
self.assertNotIn("AllowedGroups", refresh)
def test_claim_gating_and_targeted_removal_match_source(self):
callback = (ROOT / "internal/api/oidc_handlers.go").read_text()
for marker in ("len(provider.AllowedEmails) > 0", "len(provider.AllowedDomains) > 0",
"len(provider.AllowedGroups) > 0", "applySSORoleAssignments"):
self.assertIn(marker, callback)
providers = (ROOT / "internal/api/identity_sso_handlers.go").read_text()
mutations = providers.split("func (r *Router) handleUpdateSSOProvider(", 1)[1].split(
"func (r *Router) saveSSOConfig(", 1
)[0]
self.assertNotIn("InvalidateUserSessions", mutations)
self.assertNotIn("InvalidateSession", mutations)
remove = (ROOT / "internal/api/access_control_handlers.go").read_text()
self.assertIn("InvalidateUserSessions(username)", remove)
self.assertIn('"Cannot remove your own user access"', remove)
rbac = (ROOT / "docs/RBAC.md").read_text()
self.assertIn("### Removing User Access", rbac)
self.assertIn("Requires:", rbac)
def test_served_guide_matches_source(self):
self.assertEqual(GUIDE.read_bytes(), (ROOT / "frontend-modern/public/docs/OIDC.md").read_bytes())
if __name__ == "__main__":
unittest.main()