Files
Gondulf/tests/unit/test_config.py
Phil Skentelbery 05b4ff7a6b feat(phase-3): implement token endpoint and OAuth 2.0 flow
Phase 3 Implementation:
- Token service with secure token generation and validation
- Token endpoint (POST /token) with OAuth 2.0 compliance
- Database migration 003 for tokens table
- Authorization code validation and single-use enforcement

Phase 1 Updates:
- Enhanced CodeStore to support dict values with JSON serialization
- Maintains backward compatibility

Phase 2 Updates:
- Authorization codes now include PKCE fields, used flag, timestamps
- Complete metadata structure for token exchange

Security:
- 256-bit cryptographically secure tokens (secrets.token_urlsafe)
- SHA-256 hashed storage (no plaintext)
- Constant-time comparison for validation
- Single-use code enforcement with replay detection

Testing:
- 226 tests passing (100%)
- 87.27% coverage (exceeds 80% requirement)
- OAuth 2.0 compliance verified

This completes the v1.0.0 MVP with full IndieAuth authorization code flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 14:24:06 -07:00

183 lines
7.4 KiB
Python

"""
Unit tests for configuration module.
Tests environment variable loading, validation, and error handling.
"""
import os
import pytest
from gondulf.config import Config, ConfigurationError
class TestConfigLoad:
"""Tests for Config.load() method."""
def test_load_with_valid_secret_key(self, monkeypatch):
"""Test configuration loads successfully with valid SECRET_KEY."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
Config.load()
assert Config.SECRET_KEY == "a" * 32
def test_load_missing_secret_key_raises_error(self, monkeypatch):
"""Test that missing SECRET_KEY raises ConfigurationError."""
monkeypatch.delenv("GONDULF_SECRET_KEY", raising=False)
with pytest.raises(ConfigurationError, match="GONDULF_SECRET_KEY is required"):
Config.load()
def test_load_short_secret_key_raises_error(self, monkeypatch):
"""Test that SECRET_KEY shorter than 32 chars raises error."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "short")
with pytest.raises(ConfigurationError, match="at least 32 characters"):
Config.load()
def test_load_database_url_default(self, monkeypatch):
"""Test DATABASE_URL defaults to sqlite:///./data/gondulf.db."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.delenv("GONDULF_DATABASE_URL", raising=False)
Config.load()
assert Config.DATABASE_URL == "sqlite:///./data/gondulf.db"
def test_load_database_url_custom(self, monkeypatch):
"""Test DATABASE_URL can be customized."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.setenv("GONDULF_DATABASE_URL", "sqlite:////tmp/test.db")
Config.load()
assert Config.DATABASE_URL == "sqlite:////tmp/test.db"
def test_load_smtp_configuration_defaults(self, monkeypatch):
"""Test SMTP configuration uses sensible defaults."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
for key in [
"GONDULF_SMTP_HOST",
"GONDULF_SMTP_PORT",
"GONDULF_SMTP_USERNAME",
"GONDULF_SMTP_PASSWORD",
"GONDULF_SMTP_FROM",
"GONDULF_SMTP_USE_TLS",
]:
monkeypatch.delenv(key, raising=False)
Config.load()
assert Config.SMTP_HOST == "localhost"
assert Config.SMTP_PORT == 587
assert Config.SMTP_USERNAME is None
assert Config.SMTP_PASSWORD is None
assert Config.SMTP_FROM == "noreply@example.com"
assert Config.SMTP_USE_TLS is True
def test_load_smtp_configuration_custom(self, monkeypatch):
"""Test SMTP configuration can be customized."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.setenv("GONDULF_SMTP_HOST", "smtp.gmail.com")
monkeypatch.setenv("GONDULF_SMTP_PORT", "465")
monkeypatch.setenv("GONDULF_SMTP_USERNAME", "user@gmail.com")
monkeypatch.setenv("GONDULF_SMTP_PASSWORD", "password123")
monkeypatch.setenv("GONDULF_SMTP_FROM", "sender@example.com")
monkeypatch.setenv("GONDULF_SMTP_USE_TLS", "false")
Config.load()
assert Config.SMTP_HOST == "smtp.gmail.com"
assert Config.SMTP_PORT == 465
assert Config.SMTP_USERNAME == "user@gmail.com"
assert Config.SMTP_PASSWORD == "password123"
assert Config.SMTP_FROM == "sender@example.com"
assert Config.SMTP_USE_TLS is False
def test_load_token_expiry_default(self, monkeypatch):
"""Test TOKEN_EXPIRY defaults to 3600 seconds."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.delenv("GONDULF_TOKEN_EXPIRY", raising=False)
Config.load()
assert Config.TOKEN_EXPIRY == 3600
def test_load_code_expiry_default(self, monkeypatch):
"""Test CODE_EXPIRY defaults to 600 seconds."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.delenv("GONDULF_CODE_EXPIRY", raising=False)
Config.load()
assert Config.CODE_EXPIRY == 600
def test_load_token_expiry_custom(self, monkeypatch):
"""Test TOKEN_EXPIRY can be customized."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.setenv("GONDULF_TOKEN_EXPIRY", "7200")
Config.load()
assert Config.TOKEN_EXPIRY == 7200
def test_load_log_level_default_production(self, monkeypatch):
"""Test LOG_LEVEL defaults to INFO in production mode."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.delenv("GONDULF_LOG_LEVEL", raising=False)
monkeypatch.delenv("GONDULF_DEBUG", raising=False)
Config.load()
assert Config.LOG_LEVEL == "INFO"
assert Config.DEBUG is False
def test_load_log_level_default_debug(self, monkeypatch):
"""Test LOG_LEVEL defaults to DEBUG when DEBUG=true."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.delenv("GONDULF_LOG_LEVEL", raising=False)
monkeypatch.setenv("GONDULF_DEBUG", "true")
Config.load()
assert Config.LOG_LEVEL == "DEBUG"
assert Config.DEBUG is True
def test_load_log_level_custom(self, monkeypatch):
"""Test LOG_LEVEL can be customized."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.setenv("GONDULF_LOG_LEVEL", "WARNING")
Config.load()
assert Config.LOG_LEVEL == "WARNING"
def test_load_invalid_log_level_raises_error(self, monkeypatch):
"""Test invalid LOG_LEVEL raises ConfigurationError."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.setenv("GONDULF_LOG_LEVEL", "INVALID")
with pytest.raises(ConfigurationError, match="must be one of"):
Config.load()
class TestConfigValidate:
"""Tests for Config.validate() method."""
def test_validate_valid_configuration(self, monkeypatch):
"""Test validation passes with valid configuration."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
Config.load()
Config.validate() # Should not raise
def test_validate_smtp_port_too_low(self, monkeypatch):
"""Test validation fails when SMTP_PORT < 1."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
Config.load()
Config.SMTP_PORT = 0
with pytest.raises(ConfigurationError, match="must be between 1 and 65535"):
Config.validate()
def test_validate_smtp_port_too_high(self, monkeypatch):
"""Test validation fails when SMTP_PORT > 65535."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
Config.load()
Config.SMTP_PORT = 70000
with pytest.raises(ConfigurationError, match="must be between 1 and 65535"):
Config.validate()
def test_validate_token_expiry_negative(self, monkeypatch):
"""Test validation fails when TOKEN_EXPIRY < 300."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
Config.load()
Config.TOKEN_EXPIRY = -1
with pytest.raises(ConfigurationError, match="must be at least 300 seconds"):
Config.validate()
def test_validate_code_expiry_zero(self, monkeypatch):
"""Test validation fails when CODE_EXPIRY <= 0."""
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
Config.load()
Config.CODE_EXPIRY = 0
with pytest.raises(ConfigurationError, match="must be positive"):
Config.validate()