Files
TimeTracker/tests/test_oidc_logout.py
Dries Peeters 90dde470da style: standardize code formatting and normalize line endings
- Normalize line endings from CRLF to LF across all files to match .editorconfig
- Standardize quote style from single quotes to double quotes
- Normalize whitespace and formatting throughout codebase
- Apply consistent code style across 372 files including:
  * Application code (models, routes, services, utils)
  * Test files
  * Configuration files
  * CI/CD workflows

This ensures consistency with the project's .editorconfig settings and
improves code maintainability.
2025-11-28 20:05:37 +01:00

234 lines
9.0 KiB
Python

"""
Tests for OIDC logout behavior
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from flask import session, url_for
from app.models import User
from app import db
@pytest.fixture
def oidc_user(app):
"""Create a test user with OIDC linkage."""
with app.app_context():
user = User(username="oidc_test_user", email="oidc@example.com", full_name="OIDC Test User")
# Set OIDC attributes after creation
user.oidc_issuer = "https://idp.example.com"
user.oidc_sub = "test-sub-123"
db.session.add(user)
db.session.commit()
yield user
db.session.delete(user)
db.session.commit()
@pytest.fixture
def oidc_authenticated_client(client, oidc_user):
"""Client with an authenticated OIDC user."""
with client:
with client.session_transaction() as sess:
sess["_user_id"] = str(oidc_user.id)
sess["oidc_id_token"] = "mock_id_token_12345"
yield client
# ============================================================================
# Unit Tests: OIDC Logout Behavior
# ============================================================================
@pytest.mark.unit
@pytest.mark.security
def test_logout_without_post_logout_uri_config(oidc_authenticated_client, app):
"""
Test that when OIDC_POST_LOGOUT_REDIRECT_URI is not set,
logout performs local logout only and redirects to login page.
This fixes the issue where Authelia (and other providers without
RP-Initiated Logout support) would receive incorrect redirect requests.
"""
with app.app_context():
# Ensure OIDC_POST_LOGOUT_REDIRECT_URI is not set
app.config["AUTH_METHOD"] = "oidc"
if hasattr(app.config, "OIDC_POST_LOGOUT_REDIRECT_URI"):
delattr(app.config, "OIDC_POST_LOGOUT_REDIRECT_URI")
# Mock oauth client to prevent actual OIDC calls
with patch("app.routes.auth.oauth") as mock_oauth:
mock_client = MagicMock()
mock_oauth.create_client.return_value = mock_client
# Perform logout
response = oidc_authenticated_client.get("/logout", follow_redirects=False)
# Should redirect to local login page, NOT to IdP
assert response.status_code == 302
assert response.location.endswith("/login")
# OAuth client should not have been created since no post_logout URI
mock_oauth.create_client.assert_not_called()
@pytest.mark.unit
@pytest.mark.security
def test_logout_with_post_logout_uri_config(oidc_authenticated_client, app):
"""
Test that when OIDC_POST_LOGOUT_REDIRECT_URI is set,
logout attempts RP-Initiated Logout at the provider.
"""
with app.app_context():
# Mock oauth client and Config
with patch("app.routes.auth.oauth") as mock_oauth, patch("app.routes.auth.Config") as mock_config:
# Configure OIDC with post-logout redirect
mock_config.AUTH_METHOD = "oidc"
mock_config.OIDC_POST_LOGOUT_REDIRECT_URI = "https://app.example.com/"
mock_client = MagicMock()
mock_metadata = {"end_session_endpoint": "https://idp.example.com/logout"}
mock_client.load_server_metadata.return_value = mock_metadata
mock_oauth.create_client.return_value = mock_client
# Perform logout
response = oidc_authenticated_client.get("/logout", follow_redirects=False)
# Should redirect to IdP logout endpoint
assert response.status_code == 302
assert "idp.example.com/logout" in response.location
assert "post_logout_redirect_uri" in response.location
assert "id_token_hint" in response.location
@pytest.mark.unit
@pytest.mark.security
def test_logout_oidc_provider_has_revocation_endpoint_only(oidc_authenticated_client, app):
"""
Test logout when provider has revocation_endpoint but no end_session_endpoint.
Should use revocation_endpoint as fallback when post_logout URI is configured.
"""
with app.app_context():
with patch("app.routes.auth.oauth") as mock_oauth, patch("app.routes.auth.Config") as mock_config:
mock_config.AUTH_METHOD = "oidc"
mock_config.OIDC_POST_LOGOUT_REDIRECT_URI = "https://app.example.com/"
mock_client = MagicMock()
mock_metadata = {"revocation_endpoint": "https://idp.example.com/revoke"}
mock_client.load_server_metadata.return_value = mock_metadata
mock_oauth.create_client.return_value = mock_client
response = oidc_authenticated_client.get("/logout", follow_redirects=False)
# Should redirect to revocation endpoint
assert response.status_code == 302
assert "idp.example.com/revoke" in response.location
@pytest.mark.unit
@pytest.mark.security
def test_logout_local_auth_method(authenticated_client, app):
"""Test that local auth method doesn't try OIDC logout."""
with app.app_context():
app.config["AUTH_METHOD"] = "local"
with patch("app.routes.auth.oauth") as mock_oauth:
response = authenticated_client.get("/logout", follow_redirects=False)
# Should redirect to login
assert response.status_code == 302
assert response.location.endswith("/login")
# Should not attempt OIDC operations
mock_oauth.create_client.assert_not_called()
@pytest.mark.unit
@pytest.mark.security
def test_logout_clears_oidc_id_token_from_session(oidc_authenticated_client, app):
"""Test that logout removes the OIDC ID token from session."""
with app.app_context():
app.config["AUTH_METHOD"] = "oidc"
with patch("app.routes.auth.oauth"):
# Verify ID token is in session before logout
with oidc_authenticated_client.session_transaction() as sess:
assert "oidc_id_token" in sess
# Perform logout
oidc_authenticated_client.get("/logout", follow_redirects=True)
# Verify ID token is removed from session
with oidc_authenticated_client.session_transaction() as sess:
assert "oidc_id_token" not in sess
@pytest.mark.unit
@pytest.mark.security
def test_logout_with_both_auth_method_no_post_logout_uri(oidc_authenticated_client, app):
"""
Test logout with AUTH_METHOD=both and no post_logout URI.
Should perform local logout only.
"""
with app.app_context():
app.config["AUTH_METHOD"] = "both"
if hasattr(app.config, "OIDC_POST_LOGOUT_REDIRECT_URI"):
delattr(app.config, "OIDC_POST_LOGOUT_REDIRECT_URI")
with patch("app.routes.auth.oauth") as mock_oauth:
response = oidc_authenticated_client.get("/logout", follow_redirects=False)
# Should redirect to login without OIDC logout
assert response.status_code == 302
assert response.location.endswith("/login")
mock_oauth.create_client.assert_not_called()
@pytest.mark.unit
@pytest.mark.security
def test_logout_provider_metadata_load_fails_gracefully(oidc_authenticated_client, app):
"""Test that logout handles provider metadata loading failures gracefully."""
with app.app_context():
with patch("app.routes.auth.oauth") as mock_oauth, patch("app.routes.auth.Config") as mock_config:
mock_config.AUTH_METHOD = "oidc"
mock_config.OIDC_POST_LOGOUT_REDIRECT_URI = "https://app.example.com/"
mock_client = MagicMock()
# Simulate metadata loading failure
mock_client.load_server_metadata.side_effect = Exception("Metadata unavailable")
mock_oauth.create_client.return_value = mock_client
# Should fall back to local logout
response = oidc_authenticated_client.get("/logout", follow_redirects=False)
assert response.status_code == 302
assert response.location.endswith("/login")
# ============================================================================
# Smoke Tests: OIDC Logout
# ============================================================================
@pytest.mark.smoke
def test_logout_endpoint_exists(client):
"""Smoke test: Ensure logout endpoint is accessible."""
# Should redirect to login (not 404)
response = client.get("/logout", follow_redirects=False)
assert response.status_code in [302, 401] # Redirect or unauthorized, not 404
@pytest.mark.smoke
def test_logout_configuration_keys_valid(app):
"""Smoke test: Verify OIDC configuration keys are properly defined."""
with app.app_context():
from app.config import Config
# These should be accessible without errors
auth_method = getattr(Config, "AUTH_METHOD", None)
assert auth_method in ["local", "oidc", "both", None]
# OIDC_POST_LOGOUT_REDIRECT_URI should be optional
post_logout = getattr(Config, "OIDC_POST_LOGOUT_REDIRECT_URI", None)
# It's fine if it's None or a string
assert post_logout is None or isinstance(post_logout, str)