- Add generate_api_token with format 'or_api_' + 48 bytes random - Implement hash_token with SHA-256 - Add verify_api_token with timing-safe comparison (secrets.compare_digest) - Only hash stored in DB, plaintext shown once - 20 comprehensive tests with 100% coverage - Handle TypeError for non-string inputs
295 lines
8.8 KiB
Python
295 lines
8.8 KiB
Python
"""Tests for API token generation service - T15.
|
|
|
|
Tests for generating and verifying API tokens with SHA-256 hashing.
|
|
"""
|
|
|
|
import hashlib
|
|
import secrets
|
|
|
|
import pytest
|
|
|
|
|
|
pytestmark = [pytest.mark.unit, pytest.mark.security]
|
|
|
|
|
|
class TestGenerateAPIToken:
|
|
"""Test suite for generate_api_token function."""
|
|
|
|
def test_generate_api_token_returns_tuple(self):
|
|
"""Test that generate_api_token returns a tuple."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import generate_api_token
|
|
|
|
# Act
|
|
result = generate_api_token()
|
|
|
|
# Assert
|
|
assert isinstance(result, tuple)
|
|
assert len(result) == 2
|
|
|
|
def test_generate_api_token_returns_plaintext_and_hash(self):
|
|
"""Test that generate_api_token returns (plaintext, hash)."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import generate_api_token
|
|
|
|
# Act
|
|
plaintext, token_hash = generate_api_token()
|
|
|
|
# Assert
|
|
assert isinstance(plaintext, str)
|
|
assert isinstance(token_hash, str)
|
|
assert len(plaintext) > 0
|
|
assert len(token_hash) > 0
|
|
|
|
def test_generate_api_token_starts_with_prefix(self):
|
|
"""Test that plaintext token starts with 'or_api_'."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import generate_api_token
|
|
|
|
# Act
|
|
plaintext, _ = generate_api_token()
|
|
|
|
# Assert
|
|
assert plaintext.startswith("or_api_")
|
|
|
|
def test_generate_api_token_generates_different_tokens(self):
|
|
"""Test that each call generates a different token."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import generate_api_token
|
|
|
|
# Act
|
|
plaintext1, hash1 = generate_api_token()
|
|
plaintext2, hash2 = generate_api_token()
|
|
|
|
# Assert
|
|
assert plaintext1 != plaintext2
|
|
assert hash1 != hash2
|
|
|
|
def test_generate_api_token_hash_is_sha256(self):
|
|
"""Test that hash is SHA-256 of the plaintext."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import generate_api_token
|
|
|
|
# Act
|
|
plaintext, token_hash = generate_api_token()
|
|
|
|
# Calculate expected hash
|
|
expected_hash = hashlib.sha256(plaintext.encode()).hexdigest()
|
|
|
|
# Assert
|
|
assert token_hash == expected_hash
|
|
assert len(token_hash) == 64 # SHA-256 hex is 64 chars
|
|
|
|
def test_generate_api_token_plaintext_sufficient_length(self):
|
|
"""Test that plaintext token has sufficient length."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import generate_api_token
|
|
|
|
# Act
|
|
plaintext, _ = generate_api_token()
|
|
|
|
# Assert - or_api_ prefix + 48 chars of token_urlsafe
|
|
assert len(plaintext) > 50 # prefix (7) + 48 chars = at least 55
|
|
|
|
|
|
class TestHashToken:
|
|
"""Test suite for hash_token function."""
|
|
|
|
def test_hash_token_returns_string(self):
|
|
"""Test that hash_token returns a string."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import hash_token
|
|
|
|
token = "test-token"
|
|
|
|
# Act
|
|
result = hash_token(token)
|
|
|
|
# Assert
|
|
assert isinstance(result, str)
|
|
assert len(result) == 64 # SHA-256 hex
|
|
|
|
def test_hash_token_is_sha256(self):
|
|
"""Test that hash_token produces SHA-256 hash."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import hash_token
|
|
|
|
token = "or_api_test_token"
|
|
|
|
# Act
|
|
result = hash_token(token)
|
|
|
|
# Assert
|
|
expected = hashlib.sha256(token.encode()).hexdigest()
|
|
assert result == expected
|
|
|
|
def test_hash_token_consistent(self):
|
|
"""Test that hash_token produces consistent results."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import hash_token
|
|
|
|
token = "consistent-token"
|
|
|
|
# Act
|
|
hash1 = hash_token(token)
|
|
hash2 = hash_token(token)
|
|
|
|
# Assert
|
|
assert hash1 == hash2
|
|
|
|
|
|
class TestVerifyAPIToken:
|
|
"""Test suite for verify_api_token function."""
|
|
|
|
def test_verify_api_token_valid_returns_true(self):
|
|
"""Test that verify_api_token returns True for valid token."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import (
|
|
generate_api_token,
|
|
verify_api_token,
|
|
)
|
|
|
|
plaintext, token_hash = generate_api_token()
|
|
|
|
# Act
|
|
result = verify_api_token(plaintext, token_hash)
|
|
|
|
# Assert
|
|
assert result is True
|
|
|
|
def test_verify_api_token_invalid_returns_false(self):
|
|
"""Test that verify_api_token returns False for invalid token."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import (
|
|
generate_api_token,
|
|
verify_api_token,
|
|
)
|
|
|
|
_, token_hash = generate_api_token()
|
|
wrong_plaintext = "wrong-token"
|
|
|
|
# Act
|
|
result = verify_api_token(wrong_plaintext, token_hash)
|
|
|
|
# Assert
|
|
assert result is False
|
|
|
|
def test_verify_api_token_wrong_hash_returns_false(self):
|
|
"""Test that verify_api_token returns False with wrong hash."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import (
|
|
generate_api_token,
|
|
verify_api_token,
|
|
)
|
|
|
|
plaintext, _ = generate_api_token()
|
|
wrong_hash = hashlib.sha256("different".encode()).hexdigest()
|
|
|
|
# Act
|
|
result = verify_api_token(plaintext, wrong_hash)
|
|
|
|
# Assert
|
|
assert result is False
|
|
|
|
def test_verify_api_token_uses_timing_safe_comparison(self):
|
|
"""Test that verify_api_token uses timing-safe comparison."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import (
|
|
generate_api_token,
|
|
verify_api_token,
|
|
)
|
|
|
|
plaintext, token_hash = generate_api_token()
|
|
|
|
# Act - Should not raise any error and work correctly
|
|
result = verify_api_token(plaintext, token_hash)
|
|
|
|
# Assert
|
|
assert result is True
|
|
|
|
def test_verify_api_token_empty_strings(self):
|
|
"""Test verify_api_token with empty strings."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import verify_api_token
|
|
|
|
# Act
|
|
result = verify_api_token("", "")
|
|
|
|
# Assert
|
|
assert result is False
|
|
|
|
|
|
class TestTokenFormat:
|
|
"""Test suite for token format validation."""
|
|
|
|
def test_token_contains_only_urlsafe_characters(self):
|
|
"""Test that token contains only URL-safe characters."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import generate_api_token
|
|
|
|
# Act
|
|
plaintext, _ = generate_api_token()
|
|
token_part = plaintext.replace("or_api_", "")
|
|
|
|
# Assert - URL-safe base64 chars: A-Z, a-z, 0-9, -, _
|
|
urlsafe_chars = set(
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
)
|
|
assert all(c in urlsafe_chars for c in token_part)
|
|
|
|
def test_hash_is_hexadecimal(self):
|
|
"""Test that hash is valid hexadecimal."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import generate_api_token
|
|
|
|
# Act
|
|
_, token_hash = generate_api_token()
|
|
|
|
# Assert
|
|
try:
|
|
int(token_hash, 16)
|
|
except ValueError:
|
|
pytest.fail("Hash is not valid hexadecimal")
|
|
|
|
def test_generated_tokens_are_unique(self):
|
|
"""Test that generating many tokens produces unique values."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import generate_api_token
|
|
|
|
# Act
|
|
tokens = [generate_api_token()[0] for _ in range(100)]
|
|
|
|
# Assert
|
|
assert len(set(tokens)) == 100 # All unique
|
|
|
|
|
|
class TestTokenTypeValidation:
|
|
"""Test suite for type validation in token functions."""
|
|
|
|
def test_hash_token_non_string_raises_type_error(self):
|
|
"""Test that hash_token with non-string raises TypeError."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import hash_token
|
|
|
|
# Act & Assert
|
|
with pytest.raises(TypeError, match="plaintext must be a string"):
|
|
hash_token(12345)
|
|
|
|
def test_verify_api_token_non_string_plaintext_raises_type_error(self):
|
|
"""Test that verify_api_token with non-string plaintext raises TypeError."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import verify_api_token
|
|
|
|
# Act & Assert
|
|
with pytest.raises(TypeError, match="plaintext must be a string"):
|
|
verify_api_token(12345, "valid_hash")
|
|
|
|
def test_verify_api_token_non_string_hash_raises_type_error(self):
|
|
"""Test that verify_api_token with non-string hash raises TypeError."""
|
|
# Arrange
|
|
from src.openrouter_monitor.services.token import verify_api_token
|
|
|
|
# Act & Assert
|
|
with pytest.raises(TypeError, match="token_hash must be a string"):
|
|
verify_api_token("valid_plaintext", 12345)
|