mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 11:04:36 +00:00
473 lines
17 KiB
Python
473 lines
17 KiB
Python
"""
|
|
Integration tests for wallet authentication system including API key generation and validation.
|
|
Tests POST /v1/wallet/topup endpoint and authorization header validation.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlmodel import select
|
|
|
|
from routstr.core.db import ApiKey
|
|
|
|
from .utils import ResponseValidator
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_api_key_generation_valid_token(
|
|
integration_client: AsyncClient,
|
|
testmint_wallet: Any,
|
|
db_snapshot: Any,
|
|
integration_session: Any,
|
|
) -> None:
|
|
"""Test API key generation from a valid Cashu token"""
|
|
|
|
# Generate a valid test token
|
|
amount = 1000 # 1k sats
|
|
token = await testmint_wallet.mint_tokens(amount)
|
|
|
|
# Use token as Bearer auth to create API key on first use
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
response = await integration_client.get("/v1/wallet/info")
|
|
|
|
# Should succeed
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Validate response structure
|
|
assert "api_key" in data
|
|
assert "balance" in data
|
|
assert data["balance"] == amount * 1000 # Convert to msats
|
|
|
|
# API key should have proper format
|
|
api_key = data["api_key"]
|
|
assert api_key.startswith("sk-")
|
|
assert len(api_key) > 10
|
|
|
|
# Verify database state directly
|
|
hashed_key = api_key[3:] # Remove "sk-" prefix
|
|
result = await integration_session.execute(
|
|
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
|
)
|
|
db_key = result.scalar_one()
|
|
|
|
assert db_key.balance == amount * 1000
|
|
assert db_key.total_spent == 0
|
|
assert db_key.total_requests == 0
|
|
|
|
# Verify the API key can be used for authentication
|
|
integration_client.headers["Authorization"] = f"Bearer {api_key}"
|
|
wallet_response = await integration_client.get("/v1/wallet/")
|
|
assert wallet_response.status_code == 200
|
|
wallet_data = wallet_response.json()
|
|
assert wallet_data["balance"] == amount * 1000
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_api_key_generation_invalid_token(
|
|
integration_client: AsyncClient, db_snapshot: Any
|
|
) -> None:
|
|
"""Test API key generation with various invalid tokens"""
|
|
|
|
# Capture initial state
|
|
await db_snapshot.capture()
|
|
|
|
# Non-Cashu bearer values are invalid API keys (401). Malformed values that
|
|
# look like Cashu tokens use the shared Cashu taxonomy (400 invalid_token).
|
|
invalid_tokens: list[tuple[str, int, str | None]] = [
|
|
("not-a-cashu-token", 401, None),
|
|
("sk-not-a-real-api-key", 401, None),
|
|
("cashuA", 400, "invalid_cashu_token"),
|
|
("cashuA" + "x" * 1000, 400, "invalid_cashu_token"),
|
|
]
|
|
|
|
for invalid_token, expected_status, expected_code in invalid_tokens:
|
|
integration_client.headers["Authorization"] = f"Bearer {invalid_token}"
|
|
response = await integration_client.get("/v1/wallet/info")
|
|
|
|
assert response.status_code == expected_status, (
|
|
f"Token {invalid_token[:20]}... should be invalid"
|
|
)
|
|
|
|
# Validate error response
|
|
validator = ResponseValidator()
|
|
error_validation = validator.validate_error_response(
|
|
response, expected_status=expected_status, expected_error_key="detail"
|
|
)
|
|
assert error_validation["valid"]
|
|
if expected_code is not None:
|
|
assert response.json()["detail"]["error"]["code"] == expected_code
|
|
|
|
# Verify no database changes
|
|
diff = await db_snapshot.diff()
|
|
assert len(diff["api_keys"]["added"]) == 0
|
|
assert len(diff["api_keys"]["modified"]) == 0
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_token_handling(
|
|
integration_client: AsyncClient, testmint_wallet: Any, db_snapshot: Any
|
|
) -> None:
|
|
"""Test that duplicate tokens return the same API key without double-spending"""
|
|
|
|
# Generate a valid token
|
|
amount = 500 # 500 sats
|
|
token = await testmint_wallet.mint_tokens(amount)
|
|
|
|
# First use of token
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
response1 = await integration_client.get("/v1/wallet/info")
|
|
assert response1.status_code == 200
|
|
api_key1 = response1.json()["api_key"]
|
|
balance1 = response1.json()["balance"]
|
|
|
|
# Capture state after first submission
|
|
await db_snapshot.capture()
|
|
|
|
# Second use of same token - should return same API key since it's already created
|
|
response2 = await integration_client.get("/v1/wallet/info")
|
|
assert response2.status_code == 200
|
|
api_key2 = response2.json()["api_key"]
|
|
balance2 = response2.json()["balance"]
|
|
|
|
# Should return the same API key and balance
|
|
assert api_key1 == api_key2
|
|
assert balance1 == balance2
|
|
|
|
# Verify no additional database changes
|
|
diff = await db_snapshot.diff()
|
|
assert len(diff["api_keys"]["added"]) == 0
|
|
assert len(diff["api_keys"]["modified"]) == 0
|
|
|
|
# Original API key should still work with original balance
|
|
integration_client.headers["Authorization"] = f"Bearer {api_key1}"
|
|
wallet_response = await integration_client.get("/v1/wallet/")
|
|
assert wallet_response.status_code == 200
|
|
assert wallet_response.json()["balance"] == balance1
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_authorization_header_validation(
|
|
integration_client: AsyncClient, testmint_wallet: Any
|
|
) -> None:
|
|
"""Test various authorization header scenarios"""
|
|
|
|
# Create a valid API key first
|
|
token = await testmint_wallet.mint_tokens(1000)
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
response = await integration_client.get("/v1/wallet/info")
|
|
assert response.status_code == 200
|
|
valid_api_key = response.json()["api_key"]
|
|
|
|
# Test scenarios
|
|
test_cases = [
|
|
# (headers, expected_status, description)
|
|
(
|
|
{},
|
|
422,
|
|
"Missing authorization header",
|
|
), # FastAPI returns 422 for missing required headers
|
|
({"Authorization": ""}, 401, "Empty authorization header"),
|
|
({"Authorization": "Bearer"}, 401, "Bearer without token"),
|
|
({"Authorization": "Bearer "}, 401, "Bearer with space only"),
|
|
({"Authorization": "InvalidFormat"}, 401, "Invalid format"),
|
|
({"Authorization": "Basic dGVzdDp0ZXN0"}, 401, "Wrong auth type"),
|
|
({"Authorization": "Bearer invalid-key-12345"}, 401, "Invalid API key"),
|
|
({"Authorization": f"Bearer {valid_api_key}"}, 200, "Valid API key"),
|
|
({"authorization": f"Bearer {valid_api_key}"}, 200, "Lowercase header"),
|
|
({"AUTHORIZATION": f"Bearer {valid_api_key}"}, 200, "Uppercase header"),
|
|
]
|
|
|
|
for headers, expected_status, description in test_cases:
|
|
# Clear existing headers
|
|
integration_client.headers.pop("Authorization", None)
|
|
integration_client.headers.pop("authorization", None)
|
|
|
|
# Set test headers
|
|
integration_client.headers.update(headers)
|
|
|
|
# Make request to protected endpoint
|
|
response = await integration_client.get("/v1/wallet/")
|
|
|
|
assert response.status_code == expected_status, (
|
|
f"{description}: Expected {expected_status}, got {response.status_code}"
|
|
)
|
|
|
|
if expected_status == 401:
|
|
assert "detail" in response.json()
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_malformed_authorization_header(integration_client: AsyncClient) -> None:
|
|
"""Test malformed authorization headers return 400"""
|
|
|
|
# Test malformed headers that should return 400
|
|
malformed_headers = [
|
|
"Bearer\x00null", # Null byte
|
|
"Bearer " + "x" * 10000, # Extremely long token
|
|
"Bearer sk-\n\r", # Newline characters
|
|
"Bearer sk-<script>", # XSS attempt
|
|
]
|
|
|
|
for auth_value in malformed_headers:
|
|
integration_client.headers["Authorization"] = auth_value
|
|
response = await integration_client.get("/v1/wallet/")
|
|
|
|
# Should return 401 for invalid auth (not 400 in this implementation)
|
|
assert response.status_code in [
|
|
400,
|
|
401,
|
|
], f"Malformed header '{auth_value[:20]}...' should fail"
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_database_state_api_key_creation(
|
|
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
|
) -> None:
|
|
"""Test database state changes during API key creation"""
|
|
|
|
# Generate multiple tokens with different amounts
|
|
amounts = [100, 500, 1000] # sats
|
|
api_keys = []
|
|
|
|
for amount in amounts:
|
|
# Generate token and use it to create API key
|
|
token = await testmint_wallet.mint_tokens(amount)
|
|
|
|
# Use token as Bearer auth
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
response = await integration_client.get("/v1/wallet/info")
|
|
|
|
assert response.status_code == 200
|
|
api_key = response.json()["api_key"]
|
|
api_keys.append(api_key)
|
|
|
|
# Verify database record
|
|
hashed_key = api_key[3:] # Remove "sk-" prefix
|
|
result = await integration_session.execute(
|
|
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
|
)
|
|
db_key = result.scalar_one()
|
|
|
|
# Validate stored data
|
|
assert db_key.balance == amount * 1000 # msats
|
|
assert db_key.total_spent == 0
|
|
assert db_key.total_requests == 0
|
|
assert db_key.refund_address is None
|
|
assert db_key.key_expiry_time is None
|
|
|
|
# Creation timestamp should be recent (within last minute)
|
|
# Note: The model doesn't have a creation timestamp field,
|
|
# but we can verify the key exists immediately after creation
|
|
assert db_key is not None
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_api_key_with_refund_address(
|
|
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
|
) -> None:
|
|
"""Test API key creation with refund address header via proxy endpoint"""
|
|
import json
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
token = await testmint_wallet.mint_tokens(1000)
|
|
refund_address = "test@lightning.address"
|
|
|
|
# Mock the upstream request
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"content-type": "application/json"}
|
|
response_data = {
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion",
|
|
"created": 1234567890,
|
|
"model": "gpt-3.5-turbo",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "Hello!"},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
|
}
|
|
mock_response.aread = AsyncMock(return_value=json.dumps(response_data).encode())
|
|
|
|
# Use token with refund address header on proxy endpoint
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
integration_client.headers["Refund-LNURL"] = refund_address
|
|
|
|
with patch("httpx.AsyncClient.send", return_value=mock_response):
|
|
# Make a proxy POST request to create API key with refund address
|
|
response = await integration_client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "gpt-3.5-turbo",
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
"max_tokens": 10,
|
|
},
|
|
)
|
|
|
|
# Should succeed
|
|
assert response.status_code == 200
|
|
|
|
# The cashu token created an API key, but we need to get it via wallet info
|
|
# Since we can't get the API key from the proxy response, we'll skip
|
|
# the direct database verification for this test
|
|
# The refund address functionality is tested elsewhere
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_api_key_with_expiry_time(
|
|
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
|
) -> None:
|
|
"""Test API key creation with expiry time header via proxy endpoint"""
|
|
import json
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
token = await testmint_wallet.mint_tokens(1000)
|
|
refund_address = "test@lightning.address"
|
|
|
|
# Set expiry time to 1 hour from now
|
|
expiry_time = int((datetime.utcnow() + timedelta(hours=1)).timestamp())
|
|
|
|
# Mock the upstream request
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"content-type": "application/json"}
|
|
response_data = {
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion",
|
|
"created": 1234567890,
|
|
"model": "gpt-3.5-turbo",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "Hello!"},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
|
}
|
|
mock_response.aread = AsyncMock(return_value=json.dumps(response_data).encode())
|
|
|
|
# Use token with expiry time header on proxy endpoint
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
integration_client.headers["Key-Expiry-Time"] = str(expiry_time)
|
|
integration_client.headers["Refund-LNURL"] = refund_address
|
|
|
|
with patch("httpx.AsyncClient.send", return_value=mock_response):
|
|
# Make a proxy POST request to create API key with expiry time
|
|
response = await integration_client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "gpt-3.5-turbo",
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
"max_tokens": 10,
|
|
},
|
|
)
|
|
|
|
# Should succeed
|
|
assert response.status_code == 200
|
|
|
|
# The cashu token created an API key, but we need to get it via wallet info
|
|
# Since we can't get the API key from the proxy response, we'll skip
|
|
# the direct database verification for this test
|
|
# The expiry time and refund address functionality is tested elsewhere
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_authorization_with_cashu_token_directly(
|
|
integration_client: AsyncClient, testmint_wallet: Any
|
|
) -> None:
|
|
"""Test using Cashu token directly in Authorization header"""
|
|
|
|
# Generate a fresh token
|
|
token = await testmint_wallet.mint_tokens(500)
|
|
|
|
# Use token directly as bearer token
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
|
|
# First request should create API key and succeed
|
|
response = await integration_client.get("/v1/wallet/")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["balance"] == 500 * 1000 # msats
|
|
api_key = data["api_key"]
|
|
|
|
# Second request with same token should return the same API key
|
|
# (token is already associated with an API key)
|
|
response2 = await integration_client.get("/v1/wallet/")
|
|
assert response2.status_code == 200
|
|
assert response2.json()["api_key"] == api_key
|
|
assert response2.json()["balance"] == 500 * 1000
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_x_cashu_header_support(
|
|
integration_client: AsyncClient, testmint_wallet: Any
|
|
) -> None:
|
|
"""Test X-Cashu header support for authentication"""
|
|
|
|
# Generate token
|
|
token = await testmint_wallet.mint_tokens(300)
|
|
|
|
# Clear authorization header
|
|
integration_client.headers.pop("Authorization", None)
|
|
|
|
# Use X-Cashu header instead
|
|
integration_client.headers["X-Cashu"] = token
|
|
|
|
# Should work for proxy endpoints
|
|
# Note: X-Cashu might only work for specific endpoints
|
|
# Testing with a simple GET request first
|
|
response = await integration_client.get("/")
|
|
# Root endpoint doesn't require auth, so it should succeed
|
|
assert response.status_code == 200
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
async def test_database_timestamp_accuracy(
|
|
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
|
) -> None:
|
|
"""Test that creation timestamps are accurate"""
|
|
|
|
# Note: The current ApiKey model doesn't have a creation timestamp field
|
|
# This test validates that the key exists immediately after creation
|
|
|
|
token = await testmint_wallet.mint_tokens(750)
|
|
|
|
# Use token as Bearer auth
|
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
response = await integration_client.get("/v1/wallet/info")
|
|
|
|
assert response.status_code == 200
|
|
api_key = response.json()["api_key"]
|
|
|
|
# Verify key exists in database
|
|
hashed_key = api_key[3:] # Remove "sk-" prefix
|
|
result = await integration_session.execute(
|
|
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
|
)
|
|
db_key = result.scalar_one()
|
|
|
|
# Key should exist with correct balance
|
|
assert db_key is not None
|
|
assert db_key.balance == 750 * 1000
|
|
|
|
# If there was a timestamp, we would verify:
|
|
# assert before_creation <= db_key.created_at <= after_creation
|