Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

735 lines
24 KiB
Python

"""Comprehensive tests for auth-related pure logic.
Covers:
- PasswordValidator (strength validation, requirements)
- AccountLockoutManager (lock/unlock, threshold, expiry, eviction)
- URLValidator (safe URLs, redirect validation, scheme checks, DOI, sanitize)
- sanitize_for_log / strip_control_chars
- SessionManager (create, validate, expire, destroy, cleanup, user queries)
"""
import datetime
import time
from datetime import UTC, timedelta, timezone
import pytest
from local_deep_research.security.account_lockout import AccountLockoutManager
from local_deep_research.security.log_sanitizer import (
sanitize_for_log,
strip_control_chars,
)
from local_deep_research.security.password_validator import PasswordValidator
from local_deep_research.security.url_validator import (
URLValidationError,
URLValidator,
)
from local_deep_research.web.auth.session_manager import SessionManager
# ======================================================================
# PasswordValidator
# ======================================================================
class TestPasswordValidator:
"""Tests for PasswordValidator.validate_strength and get_requirements."""
def test_valid_password(self):
assert PasswordValidator.validate_strength("securepass1") == []
def test_too_short(self):
errors = PasswordValidator.validate_strength("abc1")
assert any("at least 8 characters" in e for e in errors)
def test_no_lowercase(self):
errors = PasswordValidator.validate_strength("ABCDEFG1")
assert any("lowercase" in e for e in errors)
def test_no_digit(self):
errors = PasswordValidator.validate_strength("abcdefgh")
assert any("digit" in e for e in errors)
def test_empty_password(self):
errors = PasswordValidator.validate_strength("")
assert (
len(errors) >= 2
) # too short + no lowercase + no digit (at least 2)
def test_all_failures(self):
errors = PasswordValidator.validate_strength("AB")
assert len(errors) == 3
def test_exactly_8_chars_valid(self):
assert PasswordValidator.validate_strength("abcdefg1") == []
def test_requirements_returns_list(self):
reqs = PasswordValidator.get_requirements()
assert isinstance(reqs, list)
assert len(reqs) >= 3
def test_requirements_mention_length(self):
reqs = PasswordValidator.get_requirements()
assert any("8" in r for r in reqs)
def test_unicode_letters_do_not_count_as_lowercase(self):
# Only ASCII lowercase a-z counts
errors = PasswordValidator.validate_strength("ÉÈÜÖ12345678")
assert any("lowercase" in e for e in errors)
def test_digit_anywhere(self):
assert PasswordValidator.validate_strength("aaaaaaaa1") == []
assert PasswordValidator.validate_strength("1aaaaaaaa") == []
def test_special_chars_only(self):
errors = PasswordValidator.validate_strength("!@#$%^&*")
assert any("lowercase" in e for e in errors)
assert any("digit" in e for e in errors)
# ======================================================================
# AccountLockoutManager
# ======================================================================
class TestAccountLockoutManager:
"""Tests for AccountLockoutManager lock/unlock/eviction logic."""
def _make_manager(self, threshold=3, lockout_minutes=15):
return AccountLockoutManager(
threshold=threshold, lockout_minutes=lockout_minutes
)
def test_not_locked_initially(self):
mgr = self._make_manager()
assert mgr.is_locked("alice") is False
def test_lock_after_threshold(self):
mgr = self._make_manager(threshold=3)
for _ in range(3):
mgr.record_failure("alice")
assert mgr.is_locked("alice") is True
def test_not_locked_below_threshold(self):
mgr = self._make_manager(threshold=3)
mgr.record_failure("alice")
mgr.record_failure("alice")
assert mgr.is_locked("alice") is False
def test_success_clears_failures(self):
mgr = self._make_manager(threshold=3)
mgr.record_failure("alice")
mgr.record_failure("alice")
mgr.record_success("alice")
# After clearing, need 3 more to lock
mgr.record_failure("alice")
mgr.record_failure("alice")
assert mgr.is_locked("alice") is False
def test_success_unlocks(self):
mgr = self._make_manager(threshold=2)
mgr.record_failure("alice")
mgr.record_failure("alice")
assert mgr.is_locked("alice") is True
mgr.record_success("alice")
assert mgr.is_locked("alice") is False
def test_lockout_expires(self):
mgr = self._make_manager(threshold=1, lockout_minutes=0)
mgr.record_failure("alice")
# lockout_minutes=0 means the lock is already in the past
# Force the lock time to past
with mgr._lock:
entry = mgr._state.get("alice")
if entry and entry.get("locked_until"):
entry["locked_until"] = datetime.datetime.now(
timezone.utc
) - timedelta(seconds=1)
assert mgr.is_locked("alice") is False
def test_separate_users_independent(self):
mgr = self._make_manager(threshold=2)
mgr.record_failure("alice")
mgr.record_failure("alice")
assert mgr.is_locked("alice") is True
assert mgr.is_locked("bob") is False
def test_record_success_on_unknown_user(self):
mgr = self._make_manager()
# Should not raise
mgr.record_success("nobody")
assert mgr.is_locked("nobody") is False
def test_eviction_clears_expired(self):
mgr = self._make_manager(threshold=1, lockout_minutes=0)
# Add many entries, all expired
for i in range(100):
mgr.record_failure(f"user_{i}")
# Force all locked_until to past
with mgr._lock:
for entry in mgr._state.values():
if entry.get("locked_until"):
entry["locked_until"] = datetime.datetime.now(
timezone.utc
) - timedelta(seconds=10)
# Trigger eviction by exceeding _MAX_STATE_ENTRIES
mgr._MAX_STATE_ENTRIES = 50 # lower threshold for test
mgr.record_failure("trigger_evict")
# After eviction, expired entries should be gone
with mgr._lock:
# Only the trigger entry (or fewer) should remain
assert len(mgr._state) <= 2
def test_threshold_exactly_at_boundary(self):
mgr = self._make_manager(threshold=5)
for _ in range(4):
mgr.record_failure("alice")
assert mgr.is_locked("alice") is False
mgr.record_failure("alice") # 5th attempt
assert mgr.is_locked("alice") is True
# ======================================================================
# URLValidator
# ======================================================================
class TestURLValidator:
"""Tests for URLValidator static methods."""
# --- is_unsafe_scheme ---
def test_unsafe_javascript(self):
assert URLValidator.is_unsafe_scheme("javascript:alert(1)") is True
def test_unsafe_data(self):
assert (
URLValidator.is_unsafe_scheme("data:text/html,<h1>hi</h1>") is True
)
def test_unsafe_vbscript(self):
assert URLValidator.is_unsafe_scheme("vbscript:MsgBox") is True
def test_safe_https(self):
assert URLValidator.is_unsafe_scheme("https://example.com") is False
def test_empty_not_unsafe(self):
assert URLValidator.is_unsafe_scheme("") is False
def test_case_insensitive_scheme(self):
assert URLValidator.is_unsafe_scheme("JAVASCRIPT:void(0)") is True
# --- is_safe_url ---
def test_safe_https_url(self):
assert URLValidator.is_safe_url("https://example.com") is True
def test_safe_http_url(self):
assert URLValidator.is_safe_url("http://example.com") is True
def test_ftp_is_safe(self):
assert URLValidator.is_safe_url("ftp://files.example.com") is True
def test_empty_not_safe(self):
assert URLValidator.is_safe_url("") is False
def test_none_not_safe(self):
assert URLValidator.is_safe_url(None) is False
def test_fragment_only_allowed(self):
assert URLValidator.is_safe_url("#section") is True
def test_fragment_only_disallowed(self):
assert (
URLValidator.is_safe_url("#section", allow_fragments=False) is False
)
def test_mailto_default_disallowed(self):
assert URLValidator.is_safe_url("mailto:user@example.com") is False
def test_mailto_allowed(self):
assert (
URLValidator.is_safe_url(
"mailto:user@example.com", allow_mailto=True
)
is True
)
def test_no_scheme_requires_scheme(self):
assert (
URLValidator.is_safe_url("example.com", require_scheme=True)
is False
)
def test_no_scheme_not_required(self):
assert (
URLValidator.is_safe_url("example.com", require_scheme=False)
is True
)
def test_trusted_domains(self):
assert (
URLValidator.is_safe_url(
"https://evil.com", trusted_domains=["example.com"]
)
is False
)
assert (
URLValidator.is_safe_url(
"https://example.com", trusted_domains=["example.com"]
)
is True
)
def test_subdomain_trusted(self):
assert (
URLValidator.is_safe_url(
"https://sub.example.com", trusted_domains=["example.com"]
)
is True
)
def test_double_encoding_suspicious(self):
# %2520 = double-encoded space
assert (
URLValidator.is_safe_url("https://example.com/%2520path") is False
)
def test_null_byte_suspicious(self):
assert URLValidator.is_safe_url("https://example.com/%00path") is False
# --- sanitize_url ---
def test_sanitize_adds_scheme(self):
result = URLValidator.sanitize_url("example.com")
assert result == "https://example.com"
def test_sanitize_preserves_https(self):
result = URLValidator.sanitize_url("https://example.com/path")
assert result == "https://example.com/path"
def test_sanitize_rejects_javascript(self):
assert URLValidator.sanitize_url("javascript:alert(1)") is None
def test_sanitize_empty(self):
assert URLValidator.sanitize_url("") is None
def test_sanitize_custom_default_scheme(self):
result = URLValidator.sanitize_url("example.com", default_scheme="http")
assert result is not None
assert result.startswith("http://")
# --- is_academic_url ---
def test_academic_arxiv(self):
assert (
URLValidator.is_academic_url("https://arxiv.org/abs/1234") is True
)
def test_academic_doi(self):
assert (
URLValidator.is_academic_url("https://doi.org/10.1234/test") is True
)
def test_academic_subdomain(self):
assert (
URLValidator.is_academic_url("https://www.nature.com/articles/123")
is True
)
def test_not_academic(self):
assert URLValidator.is_academic_url("https://google.com") is False
def test_academic_empty(self):
assert URLValidator.is_academic_url("") is False
# --- extract_doi ---
def test_extract_doi_from_doi_url(self):
doi = URLValidator.extract_doi("https://doi.org/10.1234/test.5678")
assert doi is not None
assert doi.startswith("10.1234")
def test_extract_doi_direct(self):
doi = URLValidator.extract_doi("10.1000/xyz123")
assert doi is not None
def test_extract_doi_none(self):
assert URLValidator.extract_doi("https://example.com") is None
# --- validate_http_url ---
def test_validate_http_url_valid(self):
assert (
URLValidator.validate_http_url("https://example.com/path") is True
)
def test_validate_http_url_no_scheme(self):
with pytest.raises(URLValidationError):
URLValidator.validate_http_url("example.com")
def test_validate_http_url_ftp_rejected(self):
with pytest.raises(URLValidationError):
URLValidator.validate_http_url("ftp://example.com")
def test_validate_http_url_empty(self):
with pytest.raises(URLValidationError):
URLValidator.validate_http_url("")
def test_validate_http_url_none(self):
with pytest.raises(URLValidationError):
URLValidator.validate_http_url(None)
# --- is_safe_redirect_url ---
def test_redirect_relative_safe(self):
assert (
URLValidator.is_safe_redirect_url(
"/dashboard", "http://localhost:5000/"
)
is True
)
def test_redirect_same_host_safe(self):
assert (
URLValidator.is_safe_redirect_url(
"http://localhost:5000/page", "http://localhost:5000/"
)
is True
)
def test_redirect_external_unsafe(self):
assert (
URLValidator.is_safe_redirect_url(
"http://evil.com/steal", "http://localhost:5000/"
)
is False
)
def test_redirect_empty(self):
assert (
URLValidator.is_safe_redirect_url("", "http://localhost:5000/")
is False
)
def test_redirect_crlf_injection(self):
assert (
URLValidator.is_safe_redirect_url(
"/page\r\nSet-Cookie: evil=1", "http://localhost:5000/"
)
is False
)
def test_redirect_protocol_relative(self):
assert (
URLValidator.is_safe_redirect_url(
"//evil.com/steal", "http://localhost:5000/"
)
is False
)
def test_redirect_backslash_bypass(self):
assert (
URLValidator.is_safe_redirect_url(
"\\evil.com", "http://localhost:5000/"
)
is False
)
def test_redirect_null_byte(self):
assert (
URLValidator.is_safe_redirect_url(
"/page\x00evil", "http://localhost:5000/"
)
is False
)
def test_redirect_path_traversal(self):
assert (
URLValidator.is_safe_redirect_url(
"/../etc/passwd", "http://localhost:5000/"
)
is False
)
def test_redirect_encoded_crlf(self):
# %0d%0a encoded
assert (
URLValidator.is_safe_redirect_url(
"/page%0d%0aSet-Cookie: x=1", "http://localhost:5000/"
)
is False
)
# --- get_safe_redirect_path ---
def test_safe_redirect_path_valid(self):
path = URLValidator.get_safe_redirect_path(
"/dashboard", "http://localhost:5000/"
)
assert path == "/dashboard"
def test_safe_redirect_path_with_query(self):
path = URLValidator.get_safe_redirect_path(
"/page?tab=settings", "http://localhost:5000/"
)
assert path is not None
assert "tab=settings" in path
def test_safe_redirect_path_unsafe(self):
path = URLValidator.get_safe_redirect_path(
"http://evil.com/steal", "http://localhost:5000/"
)
assert path is None
def test_safe_redirect_path_empty(self):
assert (
URLValidator.get_safe_redirect_path("", "http://localhost:5000/")
is None
)
# ======================================================================
# Log Sanitizer
# ======================================================================
class TestLogSanitizer:
"""Tests for sanitize_for_log and strip_control_chars."""
def test_strip_null_byte(self):
assert strip_control_chars("hello\x00world") == "helloworld"
def test_strip_newlines(self):
assert strip_control_chars("line1\nline2\r") == "line1line2"
def test_strip_tabs(self):
assert strip_control_chars("col1\tcol2") == "col1col2"
def test_preserve_normal_ascii(self):
s = "Hello World 123 !@#"
assert strip_control_chars(s) == s
def test_preserve_unicode_letters(self):
s = "Héllo Wörld 日本語"
assert strip_control_chars(s) == s
def test_strip_zero_width_chars(self):
assert strip_control_chars("a\u200bb\u200fc") == "abc"
def test_strip_bom(self):
assert strip_control_chars("\ufeffHello") == "Hello"
def test_strip_rlo(self):
# Right-to-left override
assert strip_control_chars("test\u202eevil") == "testevil"
def test_sanitize_truncates(self):
long_input = "a" * 100
result = sanitize_for_log(long_input, max_length=50)
assert len(result) == 50
assert result.endswith("...")
def test_sanitize_short_input(self):
result = sanitize_for_log("hello", max_length=50)
assert result == "hello"
def test_sanitize_exact_length(self):
s = "a" * 50
result = sanitize_for_log(s, max_length=50)
assert result == s
def test_sanitize_strips_and_truncates(self):
# Control chars stripped first, then truncated
s = "\x00" + "b" * 100
result = sanitize_for_log(s, max_length=20)
assert "\x00" not in result
assert len(result) == 20
def test_sanitize_very_small_max_length(self):
result = sanitize_for_log("abcdef", max_length=3)
assert len(result) == 3
def test_sanitize_default_max_length(self):
long_input = "a" * 200
result = sanitize_for_log(long_input)
assert len(result) == 50 # default is 50
# ======================================================================
# SessionManager
# ======================================================================
class TestSessionManager:
"""Tests for SessionManager create/validate/destroy/cleanup logic."""
def _make_manager(self, session_hours=2, remember_days=30):
"""Create a SessionManager with controlled timeouts."""
mgr = SessionManager.__new__(SessionManager)
import threading
mgr.sessions = {}
mgr._lock = threading.Lock()
mgr.session_timeout = timedelta(hours=session_hours)
mgr.remember_me_timeout = timedelta(days=remember_days)
return mgr
def test_create_session_returns_string(self):
mgr = self._make_manager()
sid = mgr.create_session("alice")
assert isinstance(sid, str)
assert len(sid) > 20 # token_urlsafe(32) is ~43 chars
def test_create_session_unique(self):
mgr = self._make_manager()
s1 = mgr.create_session("alice")
s2 = mgr.create_session("alice")
assert s1 != s2
def test_validate_returns_username(self):
mgr = self._make_manager()
sid = mgr.create_session("alice")
assert mgr.validate_session(sid) == "alice"
def test_validate_unknown_session(self):
mgr = self._make_manager()
assert mgr.validate_session("nonexistent") is None
def test_validate_updates_last_access(self):
mgr = self._make_manager()
sid = mgr.create_session("alice")
first_access = mgr.sessions[sid]["last_access"]
time.sleep(0.01)
mgr.validate_session(sid)
assert mgr.sessions[sid]["last_access"] > first_access
def test_expired_session_returns_none(self):
mgr = self._make_manager(session_hours=1)
sid = mgr.create_session("alice")
# Force session to be expired
with mgr._lock:
mgr.sessions[sid]["last_access"] = datetime.datetime.now(
UTC
) - timedelta(hours=2)
assert mgr.validate_session(sid) is None
# Session should be removed
assert sid not in mgr.sessions
def test_remember_me_uses_extended_timeout(self):
mgr = self._make_manager(session_hours=1, remember_days=30)
sid = mgr.create_session("alice", remember_me=True)
# Set last_access to 2 hours ago (would expire normal session)
with mgr._lock:
mgr.sessions[sid]["last_access"] = datetime.datetime.now(
UTC
) - timedelta(hours=2)
# Should still be valid with remember_me
assert mgr.validate_session(sid) == "alice"
def test_remember_me_expires_eventually(self):
mgr = self._make_manager(session_hours=1, remember_days=30)
sid = mgr.create_session("alice", remember_me=True)
with mgr._lock:
mgr.sessions[sid]["last_access"] = datetime.datetime.now(
UTC
) - timedelta(days=31)
assert mgr.validate_session(sid) is None
def test_destroy_session(self):
mgr = self._make_manager()
sid = mgr.create_session("alice")
mgr.destroy_session(sid)
assert mgr.validate_session(sid) is None
def test_destroy_nonexistent(self):
mgr = self._make_manager()
# Should not raise
mgr.destroy_session("nonexistent")
def test_cleanup_expired_sessions(self):
mgr = self._make_manager(session_hours=1)
sid1 = mgr.create_session("alice")
sid2 = mgr.create_session("bob")
# Expire sid1
with mgr._lock:
mgr.sessions[sid1]["last_access"] = datetime.datetime.now(
UTC
) - timedelta(hours=2)
mgr.cleanup_expired_sessions()
assert sid1 not in mgr.sessions
assert sid2 in mgr.sessions
def test_get_active_sessions_count(self):
mgr = self._make_manager()
mgr.create_session("alice")
mgr.create_session("bob")
assert mgr.get_active_sessions_count() == 2
def test_get_active_sessions_count_excludes_expired(self):
mgr = self._make_manager(session_hours=1)
sid1 = mgr.create_session("alice")
mgr.create_session("bob")
with mgr._lock:
mgr.sessions[sid1]["last_access"] = datetime.datetime.now(
UTC
) - timedelta(hours=2)
assert mgr.get_active_sessions_count() == 1
def test_get_user_sessions(self):
mgr = self._make_manager()
mgr.create_session("alice")
mgr.create_session("alice")
mgr.create_session("bob")
alice_sessions = mgr.get_user_sessions("alice")
assert len(alice_sessions) == 2
for s in alice_sessions:
assert "session_id" in s
assert s["session_id"].endswith("...")
def test_get_user_sessions_empty(self):
mgr = self._make_manager()
assert mgr.get_user_sessions("nobody") == []
def test_get_active_usernames(self):
mgr = self._make_manager()
mgr.create_session("alice")
mgr.create_session("bob")
mgr.create_session("alice")
usernames = mgr.get_active_usernames()
assert usernames == {"alice", "bob"}
def test_get_active_usernames_excludes_expired(self):
mgr = self._make_manager(session_hours=1)
sid = mgr.create_session("alice")
mgr.create_session("bob")
with mgr._lock:
mgr.sessions[sid]["last_access"] = datetime.datetime.now(
UTC
) - timedelta(hours=2)
assert mgr.get_active_usernames() == {"bob"}
def test_has_active_sessions_for(self):
mgr = self._make_manager()
mgr.create_session("alice")
assert mgr.has_active_sessions_for("alice") is True
assert mgr.has_active_sessions_for("bob") is False
def test_has_active_sessions_for_expired(self):
mgr = self._make_manager(session_hours=1)
sid = mgr.create_session("alice")
with mgr._lock:
mgr.sessions[sid]["last_access"] = datetime.datetime.now(
UTC
) - timedelta(hours=2)
assert mgr.has_active_sessions_for("alice") is False
def test_multiple_users_isolation(self):
mgr = self._make_manager()
sid_alice = mgr.create_session("alice")
sid_bob = mgr.create_session("bob")
mgr.destroy_session(sid_alice)
assert mgr.validate_session(sid_bob) == "bob"
assert mgr.has_active_sessions_for("alice") is False
assert mgr.has_active_sessions_for("bob") is True