# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import MagicMock, patch from sqlalchemy.exc import IntegrityError from superset.security.session_invalidation import ( _as_utc_timestamp, enforce_session_validity, invalidate_user_sessions, is_session_invalidated, SESSION_LOGIN_AT_KEY, ) def test_no_epoch_is_never_invalidated() -> None: """A user that was never disabled (NULL epoch) is never invalidated.""" assert is_session_invalidated(login_at=None, invalidated_at=None) is False assert is_session_invalidated(login_at=1_000.0, invalidated_at=None) is False def test_epoch_with_no_login_time_fails_closed() -> None: """A pre-feature session (no _login_at) on a disabled user is invalidated.""" epoch = datetime.now(timezone.utc) assert is_session_invalidated(login_at=None, invalidated_at=epoch) is True def test_session_before_epoch_is_invalidated() -> None: epoch = datetime.now(timezone.utc) before = (epoch - timedelta(minutes=5)).timestamp() assert is_session_invalidated(login_at=before, invalidated_at=epoch) is True def test_session_after_epoch_is_valid() -> None: """A fresh login after a disable+re-enable must not be invalidated.""" epoch = datetime.now(timezone.utc) after = (epoch + timedelta(minutes=5)).timestamp() assert is_session_invalidated(login_at=after, invalidated_at=epoch) is False def test_login_exactly_at_epoch_is_valid() -> None: epoch = datetime.now(timezone.utc) assert ( is_session_invalidated(login_at=epoch.timestamp(), invalidated_at=epoch) is False ) def test_naive_epoch_is_treated_as_utc() -> None: """ The DB column is a naive UTC ``DateTime``; the comparison must treat it as UTC, not local time (otherwise it skews by the local offset). """ aware = datetime(2026, 6, 2, 12, 0, 0, tzinfo=timezone.utc) naive = aware.replace(tzinfo=None) assert _as_utc_timestamp(naive) == aware.timestamp() just_before = aware.timestamp() - 1 just_after = aware.timestamp() + 1 assert is_session_invalidated(login_at=just_before, invalidated_at=naive) is True assert is_session_invalidated(login_at=just_after, invalidated_at=naive) is False MODULE = "superset.security.session_invalidation" def _user(*, authenticated: bool = True, guest: bool = False) -> SimpleNamespace: return SimpleNamespace(is_authenticated=authenticated, is_guest_user=guest) def test_enforce_skips_unauthenticated_user() -> None: """No authenticated user ⇒ nothing to enforce, request proceeds.""" with ( patch(f"{MODULE}.current_user", _user(authenticated=False)), patch(f"{MODULE}.logout_user") as logout, ): assert enforce_session_validity() is None logout.assert_not_called() def test_enforce_skips_guest_user() -> None: """Guest (embedded) users have their own revocation path and are skipped.""" with ( patch(f"{MODULE}.current_user", _user(guest=True)), patch(f"{MODULE}._get_user_invalidated_at") as get_epoch, patch(f"{MODULE}.logout_user") as logout, ): assert enforce_session_validity() is None get_epoch.assert_not_called() logout.assert_not_called() def test_enforce_no_epoch_leaves_session_alone() -> None: """A user with no invalidation epoch is never logged out.""" with ( patch(f"{MODULE}.current_user", _user()), patch(f"{MODULE}._get_user_invalidated_at", return_value=None), patch(f"{MODULE}.logout_user") as logout, ): assert enforce_session_validity() is None logout.assert_not_called() def test_enforce_valid_session_is_not_logged_out() -> None: """A session that logged in after the epoch stays authenticated.""" epoch = datetime.now(timezone.utc) after = (epoch + timedelta(minutes=5)).timestamp() fake_session = MagicMock() fake_session.get.return_value = after with ( patch(f"{MODULE}.current_user", _user()), patch(f"{MODULE}._get_user_invalidated_at", return_value=epoch), patch(f"{MODULE}.session", fake_session), patch(f"{MODULE}.logout_user") as logout, ): assert enforce_session_validity() is None fake_session.get.assert_called_once_with(SESSION_LOGIN_AT_KEY) logout.assert_not_called() def test_enforce_invalidated_session_is_logged_out() -> None: """A session predating the epoch is cleared and flashed a warning.""" epoch = datetime.now(timezone.utc) before = (epoch - timedelta(minutes=5)).timestamp() fake_session = MagicMock() fake_session.get.return_value = before with ( patch(f"{MODULE}.current_user", _user()), patch(f"{MODULE}._get_user_invalidated_at", return_value=epoch), patch(f"{MODULE}.session", fake_session), patch(f"{MODULE}.logout_user") as logout, patch(f"{MODULE}.flash") as flash, ): assert enforce_session_validity() is None logout.assert_called_once() fake_session.clear.assert_called_once() flash.assert_called_once() def test_enforce_fails_open_on_error() -> None: """Any error in the check logs a warning and allows the request.""" # A real (non-guest, authenticated) user so the check reaches the epoch # lookup, which then raises — exercising the fail-open handler. user = SimpleNamespace(is_authenticated=True, is_guest_user=False) with ( patch(f"{MODULE}.current_user", user), patch(f"{MODULE}._get_user_invalidated_at", side_effect=RuntimeError("boom")), patch(f"{MODULE}.logout_user") as logout, patch(f"{MODULE}.logger") as logger, ): assert enforce_session_validity() is None logout.assert_not_called() logger.warning.assert_called_once() def test_invalidate_updates_existing_row() -> None: """When a row already exists, the upsert updates it and skips the insert.""" connection = MagicMock() connection.execute.return_value.rowcount = 1 invalidate_user_sessions(connection, user_id=7) # Single UPDATE, no INSERT / SAVEPOINT. assert connection.execute.call_count == 1 connection.begin_nested.assert_not_called() def test_invalidate_inserts_when_missing() -> None: """When no row exists, the upsert inserts one inside a SAVEPOINT.""" connection = MagicMock() # First execute is the UPDATE (rowcount 0); second is the INSERT. connection.execute.return_value.rowcount = 0 invalidate_user_sessions(connection, user_id=7) assert connection.execute.call_count == 2 connection.begin_nested.assert_called_once() def test_invalidate_retries_as_update_on_race() -> None: """If a concurrent disable wins the insert, the IntegrityError is caught and the row is stamped via UPDATE instead of duplicating it.""" connection = MagicMock() update_result = SimpleNamespace(rowcount=0) retry_result = SimpleNamespace(rowcount=1) calls: list[str] = [] def execute(statement, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 compiled = str(statement).strip().upper() if compiled.startswith("UPDATE"): calls.append("update") # First UPDATE finds nothing; the retry UPDATE succeeds. return retry_result if len(calls) > 1 else update_result calls.append("insert") raise IntegrityError("insert", {}, Exception()) connection.execute.side_effect = execute invalidate_user_sessions(connection, user_id=7) # update (miss) -> insert (race loses) -> update (retry succeeds) assert calls == ["update", "insert", "update"]