feat(core): implement Phase 1 foundation infrastructure

Implements Phase 1 Foundation with all core services:

Core Components:
- Configuration management with GONDULF_ environment variables
- Database layer with SQLAlchemy and migration system
- In-memory code storage with TTL support
- Email service with SMTP and TLS support (STARTTLS + implicit TLS)
- DNS service with TXT record verification
- Structured logging with Python standard logging
- FastAPI application with health check endpoint

Database Schema:
- authorization_codes table for OAuth 2.0 authorization codes
- domains table for domain verification
- migrations table for tracking schema versions
- Simple sequential migration system (001_initial_schema.sql)

Configuration:
- Environment-based configuration with validation
- .env.example template with all GONDULF_ variables
- Fail-fast validation on startup
- Sensible defaults for optional settings

Testing:
- 96 comprehensive tests (77 unit, 5 integration)
- 94.16% code coverage (exceeds 80% requirement)
- All tests passing
- Test coverage includes:
  - Configuration loading and validation
  - Database migrations and health checks
  - In-memory storage with expiration
  - Email service (STARTTLS, implicit TLS, authentication)
  - DNS service (TXT records, domain verification)
  - Health check endpoint integration

Documentation:
- Implementation report with test results
- Phase 1 clarifications document
- ADRs for key decisions (config, database, email, logging)

Technical Details:
- Python 3.10+ with type hints
- SQLite with configurable database URL
- System DNS with public DNS fallback
- Port-based TLS detection (465=SSL, 587=STARTTLS)
- Lazy configuration loading for testability

Exit Criteria Met:
✓ All foundation services implemented
✓ Application starts without errors
✓ Health check endpoint operational
✓ Database migrations working
✓ Test coverage exceeds 80%
✓ All tests passing

Ready for Architect review and Phase 2 development.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 12:21:42 -07:00
parent 7255867fde
commit bebd47955f
39 changed files with 8134 additions and 13 deletions

218
tests/unit/test_storage.py Normal file
View File

@@ -0,0 +1,218 @@
"""
Unit tests for in-memory code storage.
Tests code storage, verification, expiration, and cleanup.
"""
import time
import pytest
from gondulf.storage import CodeStore
class TestCodeStore:
"""Tests for CodeStore class."""
def test_store_and_verify_success(self):
"""Test storing and verifying a valid code."""
store = CodeStore(ttl_seconds=60)
store.store("test@example.com", "123456")
assert store.verify("test@example.com", "123456") is True
def test_verify_wrong_code_fails(self):
"""Test verification fails with wrong code."""
store = CodeStore(ttl_seconds=60)
store.store("test@example.com", "123456")
assert store.verify("test@example.com", "wrong") is False
def test_verify_nonexistent_key_fails(self):
"""Test verification fails for nonexistent key."""
store = CodeStore(ttl_seconds=60)
assert store.verify("nonexistent@example.com", "123456") is False
def test_verify_removes_code_after_success(self):
"""Test that successful verification removes code (single-use)."""
store = CodeStore(ttl_seconds=60)
store.store("test@example.com", "123456")
# First verification succeeds
assert store.verify("test@example.com", "123456") is True
# Second verification fails (code removed)
assert store.verify("test@example.com", "123456") is False
def test_verify_expired_code_fails(self):
"""Test verification fails for expired code."""
store = CodeStore(ttl_seconds=1)
store.store("test@example.com", "123456")
# Wait for expiration
time.sleep(1.1)
assert store.verify("test@example.com", "123456") is False
def test_verify_removes_expired_code(self):
"""Test that expired codes are removed from storage."""
store = CodeStore(ttl_seconds=1)
store.store("test@example.com", "123456")
# Wait for expiration
time.sleep(1.1)
# Verification fails and removes code
store.verify("test@example.com", "123456")
# Code should be gone from storage
assert store.size() == 0
def test_get_valid_code(self):
"""Test getting a valid code without removing it."""
store = CodeStore(ttl_seconds=60)
store.store("test@example.com", "123456")
assert store.get("test@example.com") == "123456"
# Code should still be in storage
assert store.get("test@example.com") == "123456"
def test_get_nonexistent_code(self):
"""Test getting nonexistent code returns None."""
store = CodeStore(ttl_seconds=60)
assert store.get("nonexistent@example.com") is None
def test_get_expired_code(self):
"""Test getting expired code returns None."""
store = CodeStore(ttl_seconds=1)
store.store("test@example.com", "123456")
# Wait for expiration
time.sleep(1.1)
assert store.get("test@example.com") is None
def test_delete_code(self):
"""Test explicitly deleting a code."""
store = CodeStore(ttl_seconds=60)
store.store("test@example.com", "123456")
store.delete("test@example.com")
assert store.get("test@example.com") is None
def test_delete_nonexistent_code(self):
"""Test deleting nonexistent code doesn't raise error."""
store = CodeStore(ttl_seconds=60)
# Should not raise
store.delete("nonexistent@example.com")
def test_cleanup_expired_codes(self):
"""Test manual cleanup of expired codes."""
store = CodeStore(ttl_seconds=1)
# Store multiple codes
store.store("test1@example.com", "code1")
store.store("test2@example.com", "code2")
store.store("test3@example.com", "code3")
assert store.size() == 3
# Wait for expiration
time.sleep(1.1)
# Cleanup should remove all expired codes
removed = store.cleanup_expired()
assert removed == 3
assert store.size() == 0
def test_cleanup_expired_partial(self):
"""Test cleanup removes only expired codes, not valid ones."""
store = CodeStore(ttl_seconds=2)
# Store first code
store.store("test1@example.com", "code1")
# Wait 1 second
time.sleep(1)
# Store second code (will expire later)
store.store("test2@example.com", "code2")
# Wait for first code to expire
time.sleep(1.1)
# Cleanup should remove only first code
removed = store.cleanup_expired()
assert removed == 1
assert store.size() == 1
assert store.get("test2@example.com") == "code2"
def test_size(self):
"""Test size() returns correct count."""
store = CodeStore(ttl_seconds=60)
assert store.size() == 0
store.store("test1@example.com", "code1")
assert store.size() == 1
store.store("test2@example.com", "code2")
assert store.size() == 2
store.delete("test1@example.com")
assert store.size() == 1
def test_clear(self):
"""Test clear() removes all codes."""
store = CodeStore(ttl_seconds=60)
store.store("test1@example.com", "code1")
store.store("test2@example.com", "code2")
store.store("test3@example.com", "code3")
assert store.size() == 3
store.clear()
assert store.size() == 0
def test_custom_ttl(self):
"""Test custom TTL is respected."""
store = CodeStore(ttl_seconds=2)
store.store("test@example.com", "123456")
# Code valid after 1 second
time.sleep(1)
assert store.get("test@example.com") == "123456"
# Code expired after 2+ seconds
time.sleep(1.1)
assert store.get("test@example.com") is None
def test_multiple_keys(self):
"""Test storing multiple different keys."""
store = CodeStore(ttl_seconds=60)
store.store("test1@example.com", "code1")
store.store("test2@example.com", "code2")
store.store("test3@example.com", "code3")
assert store.verify("test1@example.com", "code1") is True
assert store.verify("test2@example.com", "code2") is True
assert store.verify("test3@example.com", "code3") is True
def test_overwrite_existing_code(self):
"""Test storing new code with same key overwrites old code."""
store = CodeStore(ttl_seconds=60)
store.store("test@example.com", "old_code")
store.store("test@example.com", "new_code")
assert store.verify("test@example.com", "old_code") is False
assert store.verify("test@example.com", "new_code") is True