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>
281 lines
8.6 KiB
Python
281 lines
8.6 KiB
Python
"""
|
|
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
|
|
|
|
def test_store_dict_value(self):
|
|
"""Test storing dict values for authorization code metadata."""
|
|
store = CodeStore(ttl_seconds=60)
|
|
|
|
metadata = {
|
|
"client_id": "https://client.example.com",
|
|
"redirect_uri": "https://client.example.com/callback",
|
|
"state": "xyz123",
|
|
"me": "https://user.example.com",
|
|
"scope": "profile",
|
|
"code_challenge": "abc123",
|
|
"code_challenge_method": "S256",
|
|
"created_at": 1234567890,
|
|
"expires_at": 1234568490,
|
|
"used": False
|
|
}
|
|
|
|
store.store("auth_code_123", metadata)
|
|
retrieved = store.get("auth_code_123")
|
|
|
|
assert retrieved is not None
|
|
assert isinstance(retrieved, dict)
|
|
assert retrieved["client_id"] == "https://client.example.com"
|
|
assert retrieved["used"] is False
|
|
|
|
def test_store_dict_with_custom_ttl(self):
|
|
"""Test storing dict values with custom TTL."""
|
|
store = CodeStore(ttl_seconds=60)
|
|
|
|
metadata = {"client_id": "https://client.example.com", "used": False}
|
|
|
|
store.store("auth_code_123", metadata, ttl=120)
|
|
retrieved = store.get("auth_code_123")
|
|
|
|
assert retrieved is not None
|
|
assert isinstance(retrieved, dict)
|
|
|
|
def test_dict_value_expiration(self):
|
|
"""Test dict values expire correctly."""
|
|
store = CodeStore(ttl_seconds=1)
|
|
|
|
metadata = {"client_id": "https://client.example.com"}
|
|
store.store("auth_code_123", metadata)
|
|
|
|
# Wait for expiration
|
|
time.sleep(1.1)
|
|
|
|
assert store.get("auth_code_123") is None
|
|
|
|
def test_delete_dict_value(self):
|
|
"""Test deleting dict values."""
|
|
store = CodeStore(ttl_seconds=60)
|
|
|
|
metadata = {"client_id": "https://client.example.com"}
|
|
store.store("auth_code_123", metadata)
|
|
|
|
assert store.get("auth_code_123") is not None
|
|
|
|
store.delete("auth_code_123")
|
|
|
|
assert store.get("auth_code_123") is None
|