docs: update README and add test infrastructure

- Enhance .gitignore with comprehensive Python patterns
- Improve README with better setup and testing instructions
- Add test infrastructure:
  * Test requirements file
  * Environment setup script
  * Test runner script
  * Comprehensive test suite
  * Coverage configuration
This commit is contained in:
2025-08-08 10:54:56 +02:00
parent 9e1f2128a2
commit 0e3311df8e
12 changed files with 840 additions and 14 deletions

14
.coveragerc Normal file
View File

@@ -0,0 +1,14 @@
[run]
source = .
omit =
*/venv/*
*/tests/*
*/.venv/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:

135
.gitignore vendored
View File

@@ -1 +1,134 @@
.envrc
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# IDEs
.vscode/
.idea/
# Additional files from existing .gitignore
.envrc

View File

@@ -44,20 +44,26 @@ You can try out a live demo at [https://demo-oidc.cl1.parano.ch/](https://demo-o
cd oidc-validator
```
2. Install dependencies:
2. Set up virtual environment and install dependencies:
```bash
pip install -r requirements.txt
./setup_test_env.sh
```
3. Set environment variables:
3. Activate the virtual environment:
```bash
source venv/bin/activate
```
4. Set environment variables:
```bash
export CLIENT_ID=your_oidc_client_id
export CLIENT_SECRET=your_oidc_client_secret # Required for token refresh
```
4. Run the application:
5. Run the application:
```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
@@ -118,17 +124,30 @@ The application can be configured using the following environment variables:
### Running Tests
Currently, there are no automated tests. You can manually test the endpoints using curl:
This project includes a comprehensive test suite. To run the tests:
```bash
# Health check
curl http://localhost:8000/health
1. Set up the virtual environment:
```bash
./setup_test_env.sh
```
# Token validation (requires valid JWT token)
curl -X POST http://localhost:8000/validate-token \
-H "Content-Type: application/json" \
-d '{"id_token": "your.jwt.token.here"}'
```
2. Run the tests:
```bash
./run_tests.sh
```
This will run all tests with coverage reporting and generate an HTML coverage report in the `htmlcov/` directory.
The test suite covers:
- API endpoint testing
- Token validation logic
- Error handling
- Utility functions
- Edge cases
For more detailed information about testing, see [tests/README.md](tests/README.md).
### Deployment Notes

5
requirements-test.txt Normal file
View File

@@ -0,0 +1,5 @@
pytest>=6.2.0
pytest-cov>=2.12.0
coverage>=5.5
httpx>=0.18.0
asgi_lifespan>=1.0.1

25
run_tests.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# run_tests.sh
# Check if virtual environment exists
if [ ! -d "venv" ]; then
echo "Virtual environment not found. Setting up..."
./setup_test_env.sh
fi
# Activate the virtual environment
source venv/bin/activate
# Run tests with coverage
echo "Running tests with coverage..."
coverage erase # Clear any previous coverage data
python -m pytest tests/ --cov=. --cov-config=.coveragerc -v
# Generate HTML coverage report
echo "Generating HTML coverage report..."
coverage html
echo "Coverage report generated in htmlcov/ directory"
# Deactivate virtual environment
deactivate

27
setup_test_env.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# setup_test_env.sh
# Remove existing virtual environment if it exists
if [ -d "venv" ]; then
echo "Removing existing virtual environment..."
rm -rf venv
fi
# Create a virtual environment
python3 -m venv venv
# Activate the virtual environment
source venv/bin/activate
# Upgrade pip
pip install --upgrade pip
# Install application dependencies
pip install -r requirements.txt
# Install test dependencies
pip install -r requirements-test.txt
echo "Virtual environment setup complete!"
echo "To activate the virtual environment, run:"
echo "source venv/bin/activate"

92
tests/README.md Normal file
View File

@@ -0,0 +1,92 @@
# Testing
This directory contains tests for the OIDC validator application.
## Setup
### Using the setup script (recommended)
1. Run the setup script:
```bash
./setup_test_env.sh
```
This will create a virtual environment and install all dependencies.
### Manual setup
1. Create and activate a virtual environment:
```bash
python3 -m venv venv
source venv/bin/activate
```
2. Install application dependencies:
```bash
pip install -r requirements.txt
```
3. Install test dependencies:
```bash
pip install -r requirements-test.txt
```
## Running Tests
### Using the test runner script (recommended)
```bash
./run_tests.sh
```
This will automatically activate the virtual environment and run the tests.
### Manual execution
1. Activate the virtual environment:
```bash
source venv/bin/activate
```
2. Run tests:
```bash
# Run all tests
pytest
# Run tests with coverage (excluding tests directory)
pytest --cov=. --cov-config=.coveragerc
# Run tests with verbose output
pytest -v
```
3. Deactivate the virtual environment when done:
```bash
deactivate
```
## Coverage Reports
The test suite generates coverage reports to help you understand how well the code is tested:
- Terminal coverage report is displayed after running tests
- HTML coverage report is generated in the `htmlcov/` directory
- The coverage configuration excludes the tests directory itself from coverage statistics
## Test Structure
- `conftest.py`: Contains pytest fixtures shared across tests
- `test_main.py`: Tests for the main FastAPI application endpoints
- `test_token_validation.py`: Tests for token validation and refresh logic
- `test_utils.py`: Tests for utility functions
## Test Coverage
The tests cover:
- Health check endpoint
- Token validation endpoint
- Token refresh endpoint
- Client configuration endpoint
- Utility functions for OIDC operations
- Error handling for various failure scenarios
- Edge cases and invalid inputs

48
tests/conftest.py Normal file
View File

@@ -0,0 +1,48 @@
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture(scope="module")
def test_client():
"""Create a test client for the FastAPI app."""
with TestClient(app) as client:
yield client
@pytest.fixture
def mock_valid_token_payload():
"""Mock payload for a valid JWT token."""
return {
"iss": "https://login.infomaniak.com",
"aud": "test-client-id",
"sub": "1234567890",
"email": "rene.luria@infomaniak.com",
"exp": 9999999999,
"iat": 1609459200
}
@pytest.fixture
def mock_invalid_token_payload():
"""Mock payload for an invalid JWT token."""
return {
"iss": "https://invalid.issuer.com",
"aud": "wrong-client-id",
"sub": "1234567890",
"email": "unauthorized@example.com",
"exp": 9999999999,
"iat": 1609459200
}
@pytest.fixture
def mock_jwks():
"""Mock JWKS for token validation."""
return {
"keys": [
{
"kty": "RSA",
"kid": "test-key-id",
"use": "sig",
"n": "test-modulus",
"e": "AQAB"
}
]
}

12
tests/test_example.py Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env python3
"""
Simple test to verify the test environment is set up correctly.
"""
def test_example():
"""A simple test to verify the test framework works."""
assert True
if __name__ == "__main__":
test_example()
print("Test environment verification passed!")

153
tests/test_main.py Normal file
View File

@@ -0,0 +1,153 @@
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
from main import app
# Create a test client
client = TestClient(app)
def test_health_check():
"""Test the health check endpoint."""
response = client.get("/health")
assert response.status_code == 200
assert "status" in response.json()
assert response.json()["status"] == "healthy"
def test_favicon():
"""Test the favicon endpoint."""
response = client.get("/favicon.ico")
# The favicon endpoint serves the actual favicon file, so we expect a 200
# If the file doesn't exist, it would return a 404, but in our case it should exist
assert response.status_code == 200
def test_get_client_config():
"""Test the client configuration endpoint."""
response = client.get("/config")
assert response.status_code == 200
data = response.json()
assert "client_id" in data
assert "issuer" in data
# Just check that we get a client_id, not necessarily the test value
assert isinstance(data["client_id"], str)
assert len(data["client_id"]) > 0
@patch('main.verify_token')
def test_validate_token_success(mock_verify_token):
"""Test successful token validation."""
# Mock the verify_token function to return a valid payload
mock_verify_token.return_value = {
"email": "rene.luria@infomaniak.com",
"iss": "https://login.infomaniak.com",
"aud": "test-client-id"
}
response = client.post("/validate-token", json={
"id_token": "valid.token.here"
})
assert response.status_code == 200
data = response.json()
assert data["valid"]
assert data["user"] is not None
assert data["user"]["email"] == "rene.luria@infomaniak.com"
assert data["secret_phrase"] == "Welcome to the secret club!"
@patch('main.verify_token')
def test_validate_token_unauthorized_user(mock_verify_token):
"""Test token validation for unauthorized user."""
# Mock the verify_token function to return a payload with unauthorized user
mock_verify_token.return_value = {
"email": "unauthorized@example.com",
"iss": "https://login.infomaniak.com",
"aud": "test-client-id"
}
response = client.post("/validate-token", json={
"id_token": "valid.token.here"
})
assert response.status_code == 200
data = response.json()
assert data["valid"]
assert data["user"] is not None
assert data["user"]["email"] == "unauthorized@example.com"
assert data["secret_phrase"] is None
@patch('main.verify_token')
def test_validate_token_missing_email(mock_verify_token):
"""Test token validation when email is missing from token."""
# Mock the verify_token function to return a payload without email
mock_verify_token.return_value = {
"iss": "https://login.infomaniak.com",
"aud": "test-client-id"
}
response = client.post("/validate-token", json={
"id_token": "token.without.email"
})
assert response.status_code == 200
data = response.json()
assert not data["valid"]
assert data["error"] == "Email not found in token"
@patch('main.verify_token')
def test_validate_token_invalid(mock_verify_token):
"""Test validation with an invalid token."""
# Mock the verify_token function to raise an exception
mock_verify_token.side_effect = ValueError("Invalid token")
response = client.post("/validate-token", json={
"id_token": "invalid.token.here"
})
assert response.status_code == 200
data = response.json()
assert not data["valid"]
assert data["error"] == "Invalid token"
def test_validate_token_empty_request():
"""Test validation with empty token."""
response = client.post("/validate-token", json={
"id_token": ""
})
assert response.status_code == 200
data = response.json()
assert not data["valid"]
@patch('main.refresh_token')
def test_refresh_token_success(mock_refresh_token):
"""Test successful token refresh."""
# Mock the refresh_token function
mock_refresh_token.return_value = {
"access_token": "new-access-token",
"id_token": "new-id-token",
"expires_in": 3600,
"token_type": "Bearer"
}
response = client.post("/refresh-token", json={
"refresh_token": "valid-refresh-token"
})
assert response.status_code == 200
data = response.json()
assert data["access_token"] == "new-access-token"
assert data["id_token"] == "new-id-token"
assert data["expires_in"] == 3600
assert data["token_type"] == "Bearer"
@patch('main.refresh_token')
def test_refresh_token_failure(mock_refresh_token):
"""Test token refresh failure."""
# Mock the refresh_token function to raise an exception
mock_refresh_token.side_effect = ValueError("Invalid refresh token")
response = client.post("/refresh-token", json={
"refresh_token": "invalid-refresh-token"
})
assert response.status_code == 200
data = response.json()
assert data["error"] == "Invalid refresh token"

View File

@@ -0,0 +1,209 @@
import pytest
import requests
from unittest.mock import patch, MagicMock
import jwt
from main import (
verify_token,
refresh_token,
get_well_known_config,
get_jwks,
get_user_info_from_endpoint,
get_userinfo_endpoint,
get_token_endpoint
)
def test_verify_token_invalid_input():
"""Test verify_token with invalid inputs."""
# Test with None
with pytest.raises(ValueError, match="Invalid token provided"):
verify_token(None)
# Test with empty string
with pytest.raises(ValueError, match="Invalid token provided"):
verify_token("")
# Test with non-string
with pytest.raises(ValueError, match="Invalid token provided"):
verify_token(123)
@patch('main.get_jwks')
@patch('main.jwt.get_unverified_header')
def test_verify_token_missing_kid(mock_get_header, mock_get_jwks):
"""Test verify_token when token header is missing 'kid'."""
# Mock the token header to not include 'kid'
mock_get_header.return_value = {}
with pytest.raises(ValueError, match="Token header missing 'kid' field"):
verify_token("some.token.string")
@patch('main.get_jwks')
@patch('main.jwt.get_unverified_header')
def test_verify_token_no_matching_key(mock_get_header, mock_get_jwks):
"""Test verify_token when no matching key is found in JWKS."""
# Mock the token header to include 'kid'
mock_get_header.return_value = {"kid": "nonexistent-key"}
# Mock JWKS with a different key ID
mock_get_jwks.return_value = {
"keys": [
{
"kid": "different-key",
"kty": "RSA",
"n": "modulus",
"e": "AQAB"
}
]
}
with pytest.raises(ValueError, match="Unable to find matching key in JWKS"):
verify_token("some.token.string")
@patch('main.requests.get')
def test_get_well_known_config_success(mock_get):
"""Test successful retrieval of well-known configuration."""
# Mock successful response
mock_response = MagicMock()
mock_response.json.return_value = {
"issuer": "https://login.infomaniak.com",
"jwks_uri": "https://login.infomaniak.com/.well-known/jwks.json"
}
mock_response.text = '{"issuer": "https://login.infomaniak.com"}'
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
config = get_well_known_config()
assert "issuer" in config
assert config["issuer"] == "https://login.infomaniak.com"
@patch('main.requests.get')
def test_get_well_known_config_failure(mock_get):
"""Test failed retrieval of well-known configuration."""
# Clear cache first
get_well_known_config.cache_clear()
# Mock failed response with requests exception
mock_get.side_effect = requests.exceptions.RequestException("Network error")
with pytest.raises(ValueError, match="Failed to fetch well-known configuration"):
get_well_known_config()
@patch('main.requests.get')
def test_get_jwks_success(mock_get):
"""Test successful retrieval of JWKS."""
# Mock successful response for well-known config
with patch('main.get_well_known_config') as mock_config:
mock_config.return_value = {
"jwks_uri": "https://login.infomaniak.com/.well-known/jwks.json"
}
# Mock successful JWKS response
mock_response = MagicMock()
mock_response.json.return_value = {
"keys": [{"kid": "test-key"}]
}
mock_response.text = '{"keys": [{"kid": "test-key"}]}'
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
jwks = get_jwks()
assert "keys" in jwks
assert len(jwks["keys"]) > 0
@patch('main.requests.get')
def test_get_jwks_failure(mock_get):
"""Test failed retrieval of JWKS."""
# Clear cache first
get_jwks.cache_clear()
# Mock successful response for well-known config
with patch('main.get_well_known_config') as mock_config:
mock_config.return_value = {
"jwks_uri": "https://login.infomaniak.com/.well-known/jwks.json"
}
# Mock failed response with requests exception
mock_get.side_effect = requests.exceptions.RequestException("Network error")
with pytest.raises(ValueError, match="Failed to fetch JWKS"):
get_jwks()
@patch('main.requests.post')
def test_refresh_token_success(mock_post):
"""Test successful token refresh."""
# Mock environment variables
with patch.dict('main.os.environ', {'CLIENT_ID': 'test-client', 'CLIENT_SECRET': 'test-secret'}):
# Mock successful response
mock_response = MagicMock()
mock_response.ok = True
mock_response.json.return_value = {
"access_token": "new-access-token",
"id_token": "new-id-token",
"expires_in": 3600,
"token_type": "Bearer"
}
mock_post.return_value = mock_response
with patch('main.get_token_endpoint') as mock_endpoint:
mock_endpoint.return_value = "https://login.infomaniak.com/token"
result = refresh_token("valid-refresh-token")
assert "access_token" in result
assert result["access_token"] == "new-access-token"
@patch('main.requests.post')
def test_refresh_token_failure(mock_post):
"""Test failed token refresh."""
# Mock environment variables
with patch.dict('main.os.environ', {'CLIENT_ID': 'test-client', 'CLIENT_SECRET': 'test-secret'}):
# Mock failed response
mock_response = MagicMock()
mock_response.ok = False
mock_response.status_code = 400
mock_response.text = "Bad Request"
mock_post.return_value = mock_response
with patch('main.get_token_endpoint') as mock_endpoint:
mock_endpoint.return_value = "https://login.infomaniak.com/token"
with pytest.raises(ValueError, match="Token refresh failed"):
refresh_token("invalid-refresh-token")
def test_refresh_token_invalid_input():
"""Test refresh_token with invalid inputs."""
# Test with None
with pytest.raises(ValueError, match="Invalid refresh token provided"):
refresh_token(None)
# Test with empty string
with pytest.raises(ValueError, match="Invalid refresh token provided"):
refresh_token("")
# Test with non-string
with pytest.raises(ValueError, match="Invalid refresh token provided"):
refresh_token(123)
@patch('main.get_token_endpoint')
@patch('main.requests.post')
def test_refresh_token_missing_secret(mock_post, mock_get_token_endpoint):
"""Test refresh_token when client secret is not configured."""
# Mock the token endpoint to avoid that error
mock_get_token_endpoint.return_value = "https://login.infomaniak.com/token"
# Mock the response to avoid going further in the function
mock_response = MagicMock()
mock_response.ok = True
mock_response.json.return_value = {}
mock_post.return_value = mock_response
import main
original_client_secret = main.CLIENT_SECRET
# Temporarily set CLIENT_SECRET to empty string
main.CLIENT_SECRET = ""
try:
with pytest.raises(ValueError, match="Client secret not configured"):
refresh_token("valid-refresh-token")
finally:
# Restore original value
main.CLIENT_SECRET = original_client_secret

89
tests/test_utils.py Normal file
View File

@@ -0,0 +1,89 @@
import pytest
import requests
from unittest.mock import patch, MagicMock
from main import (
get_user_info_from_endpoint,
get_userinfo_endpoint,
get_token_endpoint
)
@patch('main.requests.get')
def test_get_user_info_from_endpoint_success(mock_get):
"""Test successful retrieval of user info from endpoint."""
# Mock successful response
mock_response = MagicMock()
mock_response.json.return_value = {
"email": "test@example.com",
"given_name": "Test",
"family_name": "User"
}
mock_response.text = '{"email": "test@example.com"}'
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
with patch('main.get_userinfo_endpoint') as mock_endpoint:
mock_endpoint.return_value = "https://login.infomaniak.com/userinfo"
user_info = get_user_info_from_endpoint("valid-access-token")
assert "email" in user_info
assert user_info["email"] == "test@example.com"
@patch('main.requests.get')
def test_get_user_info_from_endpoint_failure(mock_get):
"""Test failed retrieval of user info from endpoint."""
# Mock failed response with requests exception
mock_get.side_effect = requests.exceptions.RequestException("Network error")
with patch('main.get_userinfo_endpoint') as mock_endpoint:
mock_endpoint.return_value = "https://login.infomaniak.com/userinfo"
with pytest.raises(ValueError, match="Failed to fetch user info"):
get_user_info_from_endpoint("invalid-access-token")
@patch('main.requests.get')
def test_get_userinfo_endpoint_success(mock_get):
"""Test successful retrieval of userinfo endpoint."""
# Mock successful response for well-known config
with patch('main.get_well_known_config') as mock_config:
mock_config.return_value = {
"userinfo_endpoint": "https://login.infomaniak.com/userinfo"
}
endpoint = get_userinfo_endpoint()
assert endpoint == "https://login.infomaniak.com/userinfo"
def test_get_userinfo_endpoint_failure():
"""Test failed retrieval of userinfo endpoint."""
# Clear cache first
get_userinfo_endpoint.cache_clear()
# Mock well-known config without userinfo endpoint
with patch('main.get_well_known_config') as mock_config:
mock_config.return_value = {}
with pytest.raises(ValueError, match="Userinfo endpoint not found in well-known configuration"):
get_userinfo_endpoint()
@patch('main.requests.get')
def test_get_token_endpoint_success(mock_get):
"""Test successful retrieval of token endpoint."""
# Mock successful response for well-known config
with patch('main.get_well_known_config') as mock_config:
mock_config.return_value = {
"token_endpoint": "https://login.infomaniak.com/token"
}
endpoint = get_token_endpoint()
assert endpoint == "https://login.infomaniak.com/token"
def test_get_token_endpoint_failure():
"""Test failed retrieval of token endpoint."""
# Clear cache first
get_token_endpoint.cache_clear()
# Mock well-known config without token endpoint
with patch('main.get_well_known_config') as mock_config:
mock_config.return_value = {}
with pytest.raises(ValueError, match="Token endpoint not found in well-known configuration"):
get_token_endpoint()