chore: add token revocation, caching, concurrency, and WebDAV lock modules

This commit is contained in:
2025-11-29 10:17:47 +00:00
parent acf70b7019
commit 525784aa6f
40 changed files with 4855 additions and 566 deletions
View File
+87
View File
@@ -0,0 +1,87 @@
import pytest
import pytest_asyncio
import asyncio
import tempfile
import shutil
from pathlib import Path
@pytest_asyncio.fixture
async def temp_db_path():
path = tempfile.mkdtemp()
yield path
shutil.rmtree(path, ignore_errors=True)
@pytest_asyncio.fixture
async def db_manager(temp_db_path):
from mywebdav.database.manager import UserDatabaseManager
manager = UserDatabaseManager(Path(temp_db_path), cache_size=10, flush_interval=1)
await manager.start()
yield manager
await manager.stop()
@pytest_asyncio.fixture
async def cache():
from mywebdav.cache.layer import CacheLayer
cache = CacheLayer(maxsize=100, flush_interval=60)
await cache.start()
yield cache
await cache.stop()
@pytest_asyncio.fixture
async def lock_manager():
from mywebdav.concurrency.locks import LockManager
manager = LockManager(default_timeout=5.0, cleanup_interval=60.0)
await manager.start()
yield manager
await manager.stop()
@pytest_asyncio.fixture
async def webdav_locks():
from mywebdav.concurrency.webdav_locks import PersistentWebDAVLocks
lock_manager = PersistentWebDAVLocks()
await lock_manager.start()
yield lock_manager
await lock_manager.stop()
@pytest_asyncio.fixture
async def token_manager():
from mywebdav.auth_tokens import TokenManager
manager = TokenManager()
await manager.start()
yield manager
await manager.stop()
@pytest_asyncio.fixture
async def rate_limiter():
from mywebdav.middleware.rate_limit import RateLimiter
limiter = RateLimiter()
await limiter.start()
yield limiter
await limiter.stop()
@pytest_asyncio.fixture
async def task_queue():
from mywebdav.workers.queue import TaskQueue
queue = TaskQueue(max_workers=2)
await queue.start()
yield queue
await queue.stop()
@pytest_asyncio.fixture
async def atomic_ops():
from mywebdav.concurrency.locks import init_lock_manager, shutdown_lock_manager
from mywebdav.concurrency.atomic import AtomicOperations, init_atomic_ops
await init_lock_manager(default_timeout=5.0)
ops = init_atomic_ops()
yield ops
await shutdown_lock_manager()
+185
View File
@@ -0,0 +1,185 @@
import pytest
import asyncio
from dataclasses import dataclass
@dataclass
class MockUser:
id: int
used_storage_bytes: int
storage_quota_bytes: int
class TestAtomicOperations:
@pytest.mark.asyncio
async def test_quota_check_allowed(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=1000, storage_quota_bytes=10000)
save_called = False
async def save_callback(u):
nonlocal save_called
save_called = True
result = await atomic_ops.atomic_quota_check_and_update(user, 500, save_callback)
assert result.allowed is True
assert result.current_usage == 1000
assert result.quota == 10000
assert result.requested == 500
assert result.remaining == 9000
assert save_called is True
assert user.used_storage_bytes == 1500
@pytest.mark.asyncio
async def test_quota_check_denied(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=9500, storage_quota_bytes=10000)
save_called = False
async def save_callback(u):
nonlocal save_called
save_called = True
result = await atomic_ops.atomic_quota_check_and_update(user, 1000, save_callback)
assert result.allowed is False
assert result.remaining == 500
assert save_called is False
assert user.used_storage_bytes == 9500
@pytest.mark.asyncio
async def test_quota_check_concurrent_requests(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=1000)
async def save_callback(u):
pass
async def request_quota(amount):
return await atomic_ops.atomic_quota_check_and_update(user, amount, save_callback)
results = await asyncio.gather(*[request_quota(200) for _ in range(10)])
allowed_count = sum(1 for r in results if r.allowed)
assert allowed_count == 5
assert user.used_storage_bytes == 1000
@pytest.mark.asyncio
async def test_file_create_success(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
async def check_exists():
return None
async def create_file():
return {"id": 1, "name": "test.txt"}
result = await atomic_ops.atomic_file_create(
user, None, "test.txt", check_exists, create_file
)
assert result["name"] == "test.txt"
@pytest.mark.asyncio
async def test_file_create_exists(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
async def check_exists():
return {"id": 1, "name": "test.txt"}
async def create_file():
return {"id": 2, "name": "test.txt"}
with pytest.raises(FileExistsError):
await atomic_ops.atomic_file_create(
user, None, "test.txt", check_exists, create_file
)
@pytest.mark.asyncio
async def test_file_create_concurrent_same_name(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
created_files = []
async def check_exists():
return len(created_files) > 0
async def create_file():
file = {"id": len(created_files) + 1, "name": "test.txt"}
created_files.append(file)
return file
async def try_create():
try:
return await atomic_ops.atomic_file_create(
user, 1, "test.txt", check_exists, create_file
)
except FileExistsError:
return None
results = await asyncio.gather(*[try_create() for _ in range(5)])
successful = [r for r in results if r is not None]
assert len(successful) == 1
assert len(created_files) == 1
@pytest.mark.asyncio
async def test_folder_create_success(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
async def check_exists():
return None
async def create_folder():
return {"id": 1, "name": "Documents"}
result = await atomic_ops.atomic_folder_create(
user, None, "Documents", check_exists, create_folder
)
assert result["name"] == "Documents"
@pytest.mark.asyncio
async def test_folder_create_exists(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
async def check_exists():
return {"id": 1, "name": "Documents"}
async def create_folder():
return {"id": 2, "name": "Documents"}
with pytest.raises(FileExistsError):
await atomic_ops.atomic_folder_create(
user, None, "Documents", check_exists, create_folder
)
@pytest.mark.asyncio
async def test_file_update(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
update_called = False
async def update_callback():
nonlocal update_called
update_called = True
return {"id": 1, "updated": True}
result = await atomic_ops.atomic_file_update(user, 1, update_callback)
assert update_called is True
assert result["updated"] is True
@pytest.mark.asyncio
async def test_batch_operation(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
async def process_item(item):
if item == "fail":
raise ValueError("Failed item")
return f"processed_{item}"
result = await atomic_ops.atomic_batch_operation(
user, "test_batch", ["a", "b", "fail", "c"], process_item
)
assert len(result["results"]) == 3
assert len(result["errors"]) == 1
assert "processed_a" in result["results"]
assert result["errors"][0]["item"] == "fail"
+167
View File
@@ -0,0 +1,167 @@
import pytest
import asyncio
import time
from mywebdav.cache.layer import LRUCache
class TestLRUCache:
@pytest.mark.asyncio
async def test_set_and_get(self):
cache = LRUCache(maxsize=100)
await cache.set("key1", "value1")
entry = await cache.get("key1")
assert entry is not None
assert entry.value == "value1"
@pytest.mark.asyncio
async def test_get_missing_key(self):
cache = LRUCache(maxsize=100)
entry = await cache.get("nonexistent")
assert entry is None
@pytest.mark.asyncio
async def test_ttl_expiration(self):
cache = LRUCache(maxsize=100)
await cache.set("key1", "value1", ttl=0.1)
await asyncio.sleep(0.2)
entry = await cache.get("key1")
assert entry is None
@pytest.mark.asyncio
async def test_lru_eviction(self):
cache = LRUCache(maxsize=3)
await cache.set("key1", "value1")
await cache.set("key2", "value2")
await cache.set("key3", "value3")
await cache.set("key4", "value4")
entry1 = await cache.get("key1")
assert entry1 is None
entry4 = await cache.get("key4")
assert entry4 is not None
@pytest.mark.asyncio
async def test_delete(self):
cache = LRUCache(maxsize=100)
await cache.set("key1", "value1")
result = await cache.delete("key1")
assert result is True
entry = await cache.get("key1")
assert entry is None
@pytest.mark.asyncio
async def test_dirty_tracking(self):
cache = LRUCache(maxsize=100)
await cache.set("key1", "value1", dirty=True)
await cache.set("key2", "value2", dirty=False)
dirty_keys = await cache.get_dirty_keys()
assert "key1" in dirty_keys
assert "key2" not in dirty_keys
@pytest.mark.asyncio
async def test_mark_clean(self):
cache = LRUCache(maxsize=100)
await cache.set("key1", "value1", dirty=True)
await cache.mark_clean("key1")
dirty_keys = await cache.get_dirty_keys()
assert "key1" not in dirty_keys
@pytest.mark.asyncio
async def test_invalidate_pattern(self):
cache = LRUCache(maxsize=100)
await cache.set("user:1:profile", "data1")
await cache.set("user:1:files", "data2")
await cache.set("user:2:profile", "data3")
count = await cache.invalidate_pattern("user:1:")
assert count == 2
entry1 = await cache.get("user:1:profile")
assert entry1 is None
entry2 = await cache.get("user:2:profile")
assert entry2 is not None
class TestCacheLayer:
@pytest.mark.asyncio
async def test_get_with_loader(self, cache):
call_count = 0
async def loader():
nonlocal call_count
call_count += 1
return "loaded_value"
result1 = await cache.get("test_key", loader)
assert result1 == "loaded_value"
assert call_count == 1
result2 = await cache.get("test_key", loader)
assert result2 == "loaded_value"
assert call_count == 1
@pytest.mark.asyncio
async def test_set_and_get(self, cache):
await cache.set("key1", {"data": "value"})
result = await cache.get("key1")
assert result == {"data": "value"}
@pytest.mark.asyncio
async def test_delete(self, cache):
await cache.set("key1", "value1")
await cache.delete("key1")
result = await cache.get("key1")
assert result is None
@pytest.mark.asyncio
async def test_invalidate_user_cache(self, cache):
await cache.set("user:1:profile", "data1")
await cache.set("user:1:files", "data2")
await cache.set("user:2:profile", "data3")
await cache.invalidate_user_cache(1)
result1 = await cache.get("user:1:profile")
assert result1 is None
result2 = await cache.get("user:2:profile")
assert result2 == "data3"
@pytest.mark.asyncio
async def test_stats(self, cache):
await cache.get("miss1")
await cache.set("hit1", "value")
await cache.get("hit1")
await cache.get("hit1")
stats = cache.get_stats()
assert stats["hits"] == 2
assert stats["misses"] == 1
assert stats["sets"] == 1
@pytest.mark.asyncio
async def test_build_key(self, cache):
key = cache.build_key("user_profile", user_id=123)
assert key == "user:123:profile"
key = cache.build_key("folder_contents", user_id=1, folder_id=5)
assert key == "user:1:folder:5:contents"
@pytest.mark.asyncio
async def test_ttl_by_key_type(self, cache):
assert cache._get_ttl_for_key("user:1:profile") == 300.0
assert cache._get_ttl_for_key("folder_contents:1") == 30.0
assert cache._get_ttl_for_key("unknown_key") == 300.0
@pytest.mark.asyncio
async def test_persist_flag(self, cache):
await cache.set("persistent_key", "value", persist=True)
assert "persistent_key" in cache.dirty_keys
await cache.set("non_persistent_key", "value", persist=False)
assert "non_persistent_key" not in cache.dirty_keys
+131
View File
@@ -0,0 +1,131 @@
import pytest
from pathlib import Path
class TestUserDatabaseManager:
@pytest.mark.asyncio
async def test_master_db_initialization(self, db_manager, temp_db_path):
assert db_manager.master_db is not None
assert db_manager.master_db.connection is not None
master_path = Path(temp_db_path) / "master.db"
assert master_path.exists()
@pytest.mark.asyncio
async def test_master_tables_created(self, db_manager):
async with db_manager.get_master_connection() as conn:
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
tables = [row[0] for row in await cursor.fetchall()]
assert "users" in tables
assert "revoked_tokens" in tables
assert "rate_limits" in tables
assert "webdav_locks" in tables
@pytest.mark.asyncio
async def test_user_database_creation(self, db_manager, temp_db_path):
user_id = 1
async with db_manager.get_user_connection(user_id) as conn:
assert conn is not None
user_db_path = Path(temp_db_path) / "users" / "1" / "database.db"
assert user_db_path.exists()
@pytest.mark.asyncio
async def test_user_tables_created(self, db_manager):
user_id = 1
async with db_manager.get_user_connection(user_id) as conn:
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
tables = [row[0] for row in await cursor.fetchall()]
assert "files" in tables
assert "folders" in tables
assert "shares" in tables
assert "activities" in tables
@pytest.mark.asyncio
async def test_connection_caching(self, db_manager):
user_id = 1
async with db_manager.get_user_connection(user_id):
pass
assert user_id in db_manager.databases
async with db_manager.get_user_connection(user_id):
pass
assert len(db_manager.databases) == 1
@pytest.mark.asyncio
async def test_connection_eviction(self, db_manager):
for i in range(15):
async with db_manager.get_user_connection(i):
pass
assert len(db_manager.databases) <= db_manager.cache_size
@pytest.mark.asyncio
async def test_buffered_write(self, db_manager):
user_id = 1
async with db_manager.get_user_connection(user_id):
pass
await db_manager.execute_buffered(
user_id,
"INSERT INTO folders (name, owner_id) VALUES (?, ?)",
("test_folder", user_id)
)
user_db = db_manager.databases[user_id]
assert user_db.dirty is True
assert len(user_db.write_buffer) == 1
@pytest.mark.asyncio
async def test_flush_user(self, db_manager):
user_id = 1
async with db_manager.get_user_connection(user_id):
pass
await db_manager.execute_buffered(
user_id,
"INSERT INTO folders (name, owner_id) VALUES (?, ?)",
("test_folder", user_id)
)
await db_manager.flush_user(user_id)
user_db = db_manager.databases[user_id]
assert user_db.dirty is False
assert len(user_db.write_buffer) == 0
async with db_manager.get_user_connection(user_id) as conn:
cursor = await conn.execute("SELECT name FROM folders WHERE owner_id = ?", (user_id,))
rows = await cursor.fetchall()
assert len(rows) == 1
assert rows[0][0] == "test_folder"
@pytest.mark.asyncio
async def test_master_buffered_write(self, db_manager):
await db_manager.execute_master_buffered(
"INSERT INTO users (username, email, hashed_password) VALUES (?, ?, ?)",
("testuser", "test@test.com", "hash123")
)
assert db_manager.master_db.dirty is True
await db_manager.flush_master()
assert db_manager.master_db.dirty is False
@pytest.mark.asyncio
async def test_multiple_users_isolated(self, db_manager):
for user_id in [1, 2, 3]:
async with db_manager.get_user_connection(user_id) as conn:
await conn.execute(
"INSERT INTO folders (name, owner_id) VALUES (?, ?)",
(f"folder_user_{user_id}", user_id)
)
await conn.commit()
for user_id in [1, 2, 3]:
async with db_manager.get_user_connection(user_id) as conn:
cursor = await conn.execute("SELECT COUNT(*) FROM folders")
count = (await cursor.fetchone())[0]
assert count == 1
+158
View File
@@ -0,0 +1,158 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, AsyncMock, MagicMock
from mywebdav.monitoring.health import router, check_database, check_cache, check_locks, check_task_queue, check_storage
class TestHealthChecks:
@pytest.mark.asyncio
async def test_check_database_success(self):
with patch('mywebdav.database.get_user_db_manager') as mock_db:
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.fetchone = AsyncMock(return_value=(1,))
mock_conn.execute = AsyncMock(return_value=mock_cursor)
mock_manager = MagicMock()
mock_manager.get_master_connection.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
mock_manager.get_master_connection.return_value.__aexit__ = AsyncMock(return_value=None)
mock_db.return_value = mock_manager
result = await check_database()
assert result["ok"] is True
@pytest.mark.asyncio
async def test_check_database_failure(self):
with patch('mywebdav.database.get_user_db_manager') as mock_db:
mock_db.side_effect = Exception("Connection failed")
result = await check_database()
assert result["ok"] is False
assert "Connection failed" in result["message"]
@pytest.mark.asyncio
async def test_check_cache_success(self):
with patch('mywebdav.cache.get_cache') as mock_cache:
mock_cache_instance = MagicMock()
mock_cache_instance.get_stats.return_value = {"hits": 100, "misses": 10}
mock_cache.return_value = mock_cache_instance
result = await check_cache()
assert result["ok"] is True
assert "stats" in result
@pytest.mark.asyncio
async def test_check_cache_failure(self):
with patch('mywebdav.cache.get_cache') as mock_cache:
mock_cache.side_effect = RuntimeError("Cache not initialized")
result = await check_cache()
assert result["ok"] is False
@pytest.mark.asyncio
async def test_check_locks_success(self):
with patch('mywebdav.concurrency.get_lock_manager') as mock_locks:
mock_lock_manager = MagicMock()
mock_lock_manager.get_stats = AsyncMock(return_value={"total_locks": 5, "active_locks": 2})
mock_locks.return_value = mock_lock_manager
result = await check_locks()
assert result["ok"] is True
assert result["stats"]["total_locks"] == 5
@pytest.mark.asyncio
async def test_check_task_queue_success(self):
with patch('mywebdav.workers.get_task_queue') as mock_queue:
mock_queue_instance = MagicMock()
mock_queue_instance.get_stats = AsyncMock(return_value={"pending_tasks": 3})
mock_queue.return_value = mock_queue_instance
result = await check_task_queue()
assert result["ok"] is True
@pytest.mark.asyncio
async def test_check_storage_success(self):
with patch('mywebdav.settings.settings') as mock_settings:
mock_settings.STORAGE_PATH = "/tmp"
with patch('os.path.exists', return_value=True):
with patch('os.statvfs') as mock_statvfs:
mock_stat = type('obj', (object,), {
'f_bavail': 1000000,
'f_blocks': 2000000,
'f_frsize': 4096
})()
mock_statvfs.return_value = mock_stat
result = await check_storage()
assert result["ok"] is True
assert "free_gb" in result
assert "used_percent" in result
@pytest.mark.asyncio
async def test_check_storage_path_not_exists(self):
with patch('mywebdav.settings.settings') as mock_settings:
mock_settings.STORAGE_PATH = "/nonexistent/path"
with patch('os.path.exists', return_value=False):
result = await check_storage()
assert result["ok"] is False
class TestHealthEndpoints:
@pytest.fixture
def client(self):
from fastapi import FastAPI
app = FastAPI()
app.include_router(router)
return TestClient(app)
def test_liveness_check(self, client):
response = client.get("/health/live")
assert response.status_code == 200
assert response.json()["alive"] is True
def test_readiness_check_success(self, client):
with patch('mywebdav.monitoring.health.check_database') as mock_check:
mock_check.return_value = {"ok": True}
response = client.get("/health/ready")
assert response.status_code == 200
def test_health_check_all_healthy(self, client):
with patch('mywebdav.monitoring.health.check_database') as mock_db, \
patch('mywebdav.monitoring.health.check_cache') as mock_cache, \
patch('mywebdav.monitoring.health.check_locks') as mock_locks, \
patch('mywebdav.monitoring.health.check_task_queue') as mock_queue, \
patch('mywebdav.monitoring.health.check_storage') as mock_storage:
mock_db.return_value = {"ok": True}
mock_cache.return_value = {"ok": True}
mock_locks.return_value = {"ok": True}
mock_queue.return_value = {"ok": True}
mock_storage.return_value = {"ok": True}
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
def test_health_check_degraded(self, client):
with patch('mywebdav.monitoring.health.check_database') as mock_db, \
patch('mywebdav.monitoring.health.check_cache') as mock_cache, \
patch('mywebdav.monitoring.health.check_locks') as mock_locks, \
patch('mywebdav.monitoring.health.check_task_queue') as mock_queue, \
patch('mywebdav.monitoring.health.check_storage') as mock_storage:
mock_db.return_value = {"ok": True}
mock_cache.return_value = {"ok": False, "message": "Cache error"}
mock_locks.return_value = {"ok": True}
mock_queue.return_value = {"ok": True}
mock_storage.return_value = {"ok": True}
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "degraded"
+140
View File
@@ -0,0 +1,140 @@
import pytest
import asyncio
class TestLockManager:
@pytest.mark.asyncio
async def test_acquire_and_release(self, lock_manager):
async with lock_manager.acquire("resource1", owner="test", user_id=1) as token:
assert token is not None
assert await lock_manager.is_locked("resource1")
assert not await lock_manager.is_locked("resource1")
@pytest.mark.asyncio
async def test_lock_prevents_concurrent_access(self, lock_manager):
results = []
async def task(task_id):
async with lock_manager.acquire("shared_resource", timeout=10.0, owner=f"task{task_id}", user_id=task_id):
results.append(f"start_{task_id}")
await asyncio.sleep(0.1)
results.append(f"end_{task_id}")
await asyncio.gather(task(1), task(2), task(3))
for i in range(3):
start_idx = results.index(f"start_{i+1}")
end_idx = results.index(f"end_{i+1}")
assert end_idx == start_idx + 1
@pytest.mark.asyncio
async def test_lock_timeout(self, lock_manager):
async with lock_manager.acquire("resource1", owner="holder", user_id=1):
with pytest.raises(TimeoutError):
async with lock_manager.acquire("resource1", timeout=0.1, owner="waiter", user_id=2):
pass
@pytest.mark.asyncio
async def test_try_acquire_success(self, lock_manager):
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
assert token is not None
released = await lock_manager.release("resource1", token)
assert released is True
@pytest.mark.asyncio
async def test_try_acquire_fails_when_locked(self, lock_manager):
token1 = await lock_manager.try_acquire("resource1", owner="holder", user_id=1)
assert token1 is not None
token2 = await lock_manager.try_acquire("resource1", owner="waiter", user_id=2)
assert token2 is None
await lock_manager.release("resource1", token1)
@pytest.mark.asyncio
async def test_extend_lock(self, lock_manager):
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
assert token is not None
extended = await lock_manager.extend("resource1", token, extension=60.0)
assert extended is True
info = await lock_manager.get_lock_info("resource1")
assert info is not None
assert info.extend_count == 1
await lock_manager.release("resource1", token)
@pytest.mark.asyncio
async def test_get_lock_info(self, lock_manager):
async with lock_manager.acquire("resource1", owner="testowner", user_id=42):
info = await lock_manager.get_lock_info("resource1")
assert info is not None
assert info.owner == "testowner"
assert info.user_id == 42
@pytest.mark.asyncio
async def test_release_with_wrong_token(self, lock_manager):
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
assert token is not None
released = await lock_manager.release("resource1", "wrong_token")
assert released is False
assert await lock_manager.is_locked("resource1")
await lock_manager.release("resource1", token)
@pytest.mark.asyncio
async def test_force_release(self, lock_manager):
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
assert token is not None
released = await lock_manager.force_release("resource1", user_id=1)
assert released is True
assert not await lock_manager.is_locked("resource1")
@pytest.mark.asyncio
async def test_force_release_wrong_user(self, lock_manager):
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
assert token is not None
released = await lock_manager.force_release("resource1", user_id=2)
assert released is False
assert await lock_manager.is_locked("resource1")
await lock_manager.release("resource1", token)
@pytest.mark.asyncio
async def test_build_lock_key(self, lock_manager):
key = lock_manager.build_lock_key("quota_update", user_id=123)
assert "123" in key
assert "quota" in key
key = lock_manager.build_lock_key("file_create", user_id=1, parent_id=5, name_hash="abc")
assert "1" in key
assert "5" in key
@pytest.mark.asyncio
async def test_stats(self, lock_manager):
token1 = await lock_manager.try_acquire("resource1", owner="test1", user_id=1)
token2 = await lock_manager.try_acquire("resource2", owner="test2", user_id=2)
stats = await lock_manager.get_stats()
assert stats["total_locks"] == 2
assert stats["active_locks"] == 2
await lock_manager.release("resource1", token1)
await lock_manager.release("resource2", token2)
@pytest.mark.asyncio
async def test_concurrent_different_resources(self, lock_manager):
async def acquire_resource(resource_id):
async with lock_manager.acquire(f"resource_{resource_id}", owner=f"owner{resource_id}", user_id=resource_id):
await asyncio.sleep(0.05)
return resource_id
results = await asyncio.gather(*[acquire_resource(i) for i in range(10)])
assert sorted(results) == list(range(10))
+115
View File
@@ -0,0 +1,115 @@
import pytest
import asyncio
from mywebdav.middleware.rate_limit import RateLimitMiddleware
class TestRateLimiter:
@pytest.mark.asyncio
async def test_first_request_allowed(self, rate_limiter):
allowed, remaining, retry_after = await rate_limiter.check_rate_limit(
"192.168.1.1", "api"
)
assert allowed is True
assert remaining == 99
@pytest.mark.asyncio
async def test_rate_limit_exhausted(self, rate_limiter):
for i in range(100):
await rate_limiter.check_rate_limit("192.168.1.1", "api")
allowed, remaining, retry_after = await rate_limiter.check_rate_limit(
"192.168.1.1", "api"
)
assert allowed is False
assert remaining == 0
assert retry_after > 0
@pytest.mark.asyncio
async def test_different_keys_independent(self, rate_limiter):
for i in range(100):
await rate_limiter.check_rate_limit("192.168.1.1", "api")
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
assert allowed is False
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.2", "api")
assert allowed is True
@pytest.mark.asyncio
async def test_different_limit_types(self, rate_limiter):
for i in range(5):
await rate_limiter.check_rate_limit("192.168.1.1", "login")
allowed_login, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "login")
assert allowed_login is False
allowed_api, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
assert allowed_api is True
@pytest.mark.asyncio
async def test_reset_rate_limit(self, rate_limiter):
for i in range(100):
await rate_limiter.check_rate_limit("192.168.1.1", "api")
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
assert allowed is False
await rate_limiter.reset_rate_limit("192.168.1.1", "api")
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
assert allowed is True
@pytest.mark.asyncio
async def test_login_limit(self, rate_limiter):
for i in range(5):
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "login")
assert allowed is True
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "login")
assert allowed is False
@pytest.mark.asyncio
async def test_upload_limit(self, rate_limiter):
for i in range(20):
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "upload")
assert allowed is True
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "upload")
assert allowed is False
@pytest.mark.asyncio
async def test_concurrent_requests(self, rate_limiter):
async def make_request():
return await rate_limiter.check_rate_limit("192.168.1.1", "api")
results = await asyncio.gather(*[make_request() for _ in range(150)])
allowed_count = sum(1 for allowed, _, _ in results if allowed)
assert allowed_count == 100
class TestRateLimitMiddleware:
def test_get_limit_type_login(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/api/auth/login") == "login"
def test_get_limit_type_register(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/api/auth/register") == "register"
def test_get_limit_type_upload(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/files/upload") == "upload"
def test_get_limit_type_download(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/files/download/123") == "download"
def test_get_limit_type_webdav(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/webdav/folder/file.txt") == "webdav"
def test_get_limit_type_default(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/api/users/me") == "api"
+231
View File
@@ -0,0 +1,231 @@
import pytest
import asyncio
from mywebdav.workers.queue import TaskQueue, TaskStatus, TaskPriority, Task
class TestTaskQueue:
@pytest.mark.asyncio
async def test_enqueue_task(self, task_queue):
async def handler(**kwargs):
return "success"
task_queue.register_handler("test_handler", handler)
task_id = await task_queue.enqueue(
"default",
"test_handler",
{"key": "value"}
)
assert task_id is not None
await asyncio.sleep(0.2)
task = await task_queue.get_task_status(task_id)
assert task.status == TaskStatus.COMPLETED
assert task.result == "success"
@pytest.mark.asyncio
async def test_task_with_payload(self, task_queue):
received_payload = {}
async def handler(**kwargs):
received_payload.update(kwargs)
return kwargs
task_queue.register_handler("payload_handler", handler)
task_id = await task_queue.enqueue(
"default",
"payload_handler",
{"user_id": 1, "file_id": 123}
)
await asyncio.sleep(0.2)
task = await task_queue.get_task_status(task_id)
assert task.status == TaskStatus.COMPLETED
assert received_payload["user_id"] == 1
assert received_payload["file_id"] == 123
@pytest.mark.asyncio
async def test_failed_task_retry(self, task_queue):
attempt_count = 0
async def failing_handler(**kwargs):
nonlocal attempt_count
attempt_count += 1
if attempt_count < 3:
raise ValueError("Temporary failure")
return "success after retries"
task_queue.register_handler("retry_handler", failing_handler)
task_id = await task_queue.enqueue(
"default",
"retry_handler",
{},
max_retries=3
)
await asyncio.sleep(0.5)
task = await task_queue.get_task_status(task_id)
assert task.status == TaskStatus.COMPLETED
assert attempt_count == 3
@pytest.mark.asyncio
async def test_permanently_failed_task(self, task_queue):
async def always_failing_handler(**kwargs):
raise ValueError("Permanent failure")
task_queue.register_handler("failing_handler", always_failing_handler)
task_id = await task_queue.enqueue(
"default",
"failing_handler",
{},
max_retries=2
)
await asyncio.sleep(0.5)
task = await task_queue.get_task_status(task_id)
assert task.status == TaskStatus.FAILED
assert task.retry_count == 2
@pytest.mark.asyncio
async def test_cancel_task(self, task_queue):
async def slow_handler(**kwargs):
await asyncio.sleep(10)
return "done"
task_queue.register_handler("slow_handler", slow_handler)
task_id = await task_queue.enqueue(
"default",
"slow_handler",
{}
)
cancelled = await task_queue.cancel_task(task_id)
task = await task_queue.get_task_status(task_id)
if task.status == TaskStatus.PENDING:
assert cancelled is True
@pytest.mark.asyncio
async def test_priority_ordering(self, task_queue):
execution_order = []
async def order_handler(**kwargs):
execution_order.append(kwargs["priority"])
return kwargs["priority"]
task_queue.register_handler("order_handler", order_handler)
await task_queue.stop()
task_queue = TaskQueue(max_workers=1)
task_queue.register_handler("order_handler", order_handler)
await task_queue.enqueue("default", "order_handler", {"priority": "low"}, priority=TaskPriority.LOW)
await task_queue.enqueue("default", "order_handler", {"priority": "normal"}, priority=TaskPriority.NORMAL)
await task_queue.enqueue("default", "order_handler", {"priority": "high"}, priority=TaskPriority.HIGH)
await task_queue.enqueue("default", "order_handler", {"priority": "critical"}, priority=TaskPriority.CRITICAL)
await task_queue.start()
await asyncio.sleep(0.5)
assert execution_order[0] == "critical"
assert execution_order[1] == "high"
await task_queue.stop()
@pytest.mark.asyncio
async def test_different_queues(self, task_queue):
results = {"thumbnails": False, "cleanup": False}
async def thumbnail_handler(**kwargs):
results["thumbnails"] = True
async def cleanup_handler(**kwargs):
results["cleanup"] = True
task_queue.register_handler("thumbnail_handler", thumbnail_handler)
task_queue.register_handler("cleanup_handler", cleanup_handler)
await task_queue.enqueue("thumbnails", "thumbnail_handler", {})
await task_queue.enqueue("cleanup", "cleanup_handler", {})
await asyncio.sleep(0.3)
assert results["thumbnails"] is True
assert results["cleanup"] is True
@pytest.mark.asyncio
async def test_stats(self, task_queue):
async def handler(**kwargs):
return "done"
task_queue.register_handler("stats_handler", handler)
for _ in range(5):
await task_queue.enqueue("default", "stats_handler", {})
await asyncio.sleep(0.5)
stats = await task_queue.get_stats()
assert stats["enqueued"] == 5
assert stats["completed"] == 5
@pytest.mark.asyncio
async def test_unknown_handler(self, task_queue):
task_id = await task_queue.enqueue(
"default",
"unknown_handler",
{}
)
await asyncio.sleep(0.2)
task = await task_queue.get_task_status(task_id)
assert task.status == TaskStatus.FAILED
assert "Handler not found" in task.error
@pytest.mark.asyncio
async def test_cleanup_completed_tasks(self, task_queue):
async def handler(**kwargs):
return "done"
task_queue.register_handler("cleanup_test", handler)
task_ids = []
for _ in range(5):
task_id = await task_queue.enqueue("default", "cleanup_test", {})
task_ids.append(task_id)
await asyncio.sleep(0.3)
for task_id in task_ids:
task = await task_queue.get_task_status(task_id)
task.completed_at = 1.0
await task_queue.cleanup_completed_tasks(max_age=0)
for task_id in task_ids:
task = await task_queue.get_task_status(task_id)
assert task is None
class TestTask:
def test_task_creation(self):
task = Task(
id="task_123",
queue_name="default",
handler_name="test_handler",
payload={"key": "value"}
)
assert task.id == "task_123"
assert task.status == TaskStatus.PENDING
assert task.retry_count == 0
+138
View File
@@ -0,0 +1,138 @@
import pytest
import asyncio
import time
from jose import jwt
from mywebdav.auth_tokens import TokenInfo
from mywebdav.settings import settings
class TestTokenManager:
@pytest.mark.asyncio
async def test_create_access_token(self, token_manager):
token, jti = token_manager.create_access_token(
user_id=1,
username="testuser",
two_factor_verified=False
)
assert token is not None
assert jti is not None
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
assert payload["sub"] == "testuser"
assert payload["user_id"] == 1
assert payload["jti"] == jti
assert payload["type"] == "access"
assert payload["2fa_verified"] is False
@pytest.mark.asyncio
async def test_create_access_token_with_2fa(self, token_manager):
token, jti = token_manager.create_access_token(
user_id=1,
username="testuser",
two_factor_verified=True
)
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
assert payload["2fa_verified"] is True
@pytest.mark.asyncio
async def test_create_refresh_token(self, token_manager):
token, jti = token_manager.create_refresh_token(
user_id=1,
username="testuser"
)
assert token is not None
assert jti is not None
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
assert payload["sub"] == "testuser"
assert payload["type"] == "refresh"
@pytest.mark.asyncio
async def test_revoke_token(self, token_manager):
token, jti = token_manager.create_access_token(
user_id=1,
username="testuser"
)
await asyncio.sleep(0.01)
is_revoked_before = await token_manager.is_revoked(jti)
assert is_revoked_before is False
await token_manager.revoke_token(jti, user_id=1)
is_revoked_after = await token_manager.is_revoked(jti)
assert is_revoked_after is True
@pytest.mark.asyncio
async def test_revoke_all_user_tokens(self, token_manager):
jtis = []
for i in range(5):
_, jti = token_manager.create_access_token(
user_id=1,
username="testuser"
)
jtis.append(jti)
_, other_jti = token_manager.create_access_token(
user_id=2,
username="otheruser"
)
await asyncio.sleep(0.01)
revoked_count = await token_manager.revoke_all_user_tokens(1)
assert revoked_count == 5
for jti in jtis:
assert await token_manager.is_revoked(jti) is True
assert await token_manager.is_revoked(other_jti) is False
@pytest.mark.asyncio
async def test_is_revoked_unknown_token(self, token_manager):
is_revoked = await token_manager.is_revoked("unknown_jti")
assert is_revoked is False
@pytest.mark.asyncio
async def test_stats(self, token_manager):
token_manager.create_access_token(user_id=1, username="user1")
token_manager.create_access_token(user_id=2, username="user2")
await asyncio.sleep(0.01)
stats = await token_manager.get_stats()
assert stats["active_tokens"] >= 2
@pytest.mark.asyncio
async def test_token_tracking(self, token_manager):
_, jti = token_manager.create_access_token(
user_id=1,
username="testuser"
)
await asyncio.sleep(0.01)
assert jti in token_manager.active_tokens
await token_manager.revoke_token(jti)
assert jti not in token_manager.active_tokens
assert jti in token_manager.blacklist
class TestTokenInfo:
def test_token_info_creation(self):
info = TokenInfo(
jti="test_jti",
user_id=1,
token_type="access",
expires_at=time.time() + 3600
)
assert info.jti == "test_jti"
assert info.user_id == 1
assert info.token_type == "access"
+212
View File
@@ -0,0 +1,212 @@
import pytest
import asyncio
import time
from mywebdav.concurrency.webdav_locks import WebDAVLockInfo
class TestWebDAVLockInfo:
def test_is_expired_false(self):
lock = WebDAVLockInfo(
token="token123",
path="/test/file.txt",
path_hash="abc123",
owner="user1",
user_id=1,
timeout=3600
)
assert lock.is_expired is False
def test_is_expired_true(self):
lock = WebDAVLockInfo(
token="token123",
path="/test/file.txt",
path_hash="abc123",
owner="user1",
user_id=1,
timeout=0,
created_at=time.time() - 1
)
assert lock.is_expired is True
def test_remaining_seconds(self):
lock = WebDAVLockInfo(
token="token123",
path="/test/file.txt",
path_hash="abc123",
owner="user1",
user_id=1,
timeout=100
)
assert 99 <= lock.remaining_seconds <= 100
def test_to_dict(self):
lock = WebDAVLockInfo(
token="token123",
path="/test/file.txt",
path_hash="abc123",
owner="user1",
user_id=1
)
d = lock.to_dict()
assert d["token"] == "token123"
assert d["path"] == "/test/file.txt"
assert d["owner"] == "user1"
def test_from_dict(self):
data = {
"token": "token123",
"path": "/test/file.txt",
"path_hash": "abc123",
"owner": "user1",
"user_id": 1,
"scope": "exclusive",
"depth": "0",
"timeout": 3600,
"created_at": time.time()
}
lock = WebDAVLockInfo.from_dict(data)
assert lock.token == "token123"
assert lock.user_id == 1
class TestPersistentWebDAVLocks:
@pytest.mark.asyncio
async def test_acquire_lock(self, webdav_locks):
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
assert token is not None
assert token.startswith("opaquelocktoken:")
@pytest.mark.asyncio
async def test_acquire_same_path_same_user(self, webdav_locks):
token1 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
token2 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
assert token1 == token2
@pytest.mark.asyncio
async def test_acquire_same_path_different_user(self, webdav_locks):
token1 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
token2 = await webdav_locks.acquire_lock("/test/file.txt", "user2", user_id=2)
assert token1 is not None
assert token2 is None
@pytest.mark.asyncio
async def test_release_lock(self, webdav_locks):
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
released = await webdav_locks.release_lock("/test/file.txt", token)
assert released is True
lock_info = await webdav_locks.check_lock("/test/file.txt")
assert lock_info is None
@pytest.mark.asyncio
async def test_release_wrong_token(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
released = await webdav_locks.release_lock("/test/file.txt", "wrong_token")
assert released is False
@pytest.mark.asyncio
async def test_check_lock(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
lock_info = await webdav_locks.check_lock("/test/file.txt")
assert lock_info is not None
assert lock_info.owner == "user1"
assert lock_info.user_id == 1
@pytest.mark.asyncio
async def test_check_lock_nonexistent(self, webdav_locks):
lock_info = await webdav_locks.check_lock("/nonexistent/file.txt")
assert lock_info is None
@pytest.mark.asyncio
async def test_is_locked(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
assert await webdav_locks.is_locked("/test/file.txt") is True
assert await webdav_locks.is_locked("/test/file.txt", user_id=1) is False
assert await webdav_locks.is_locked("/test/file.txt", user_id=2) is True
@pytest.mark.asyncio
async def test_refresh_lock(self, webdav_locks):
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1, timeout=10)
lock_before = await webdav_locks.check_lock("/test/file.txt")
created_at_before = lock_before.created_at
await asyncio.sleep(0.1)
refreshed = await webdav_locks.refresh_lock("/test/file.txt", token, timeout=100)
assert refreshed is True
lock_after = await webdav_locks.check_lock("/test/file.txt")
assert lock_after.timeout == 100
assert lock_after.created_at > created_at_before
@pytest.mark.asyncio
async def test_refresh_wrong_token(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
refreshed = await webdav_locks.refresh_lock("/test/file.txt", "wrong_token")
assert refreshed is False
@pytest.mark.asyncio
async def test_force_unlock(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
unlocked = await webdav_locks.force_unlock("/test/file.txt", user_id=1)
assert unlocked is True
lock_info = await webdav_locks.check_lock("/test/file.txt")
assert lock_info is None
@pytest.mark.asyncio
async def test_force_unlock_wrong_user(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
unlocked = await webdav_locks.force_unlock("/test/file.txt", user_id=2)
assert unlocked is False
@pytest.mark.asyncio
async def test_get_user_locks(self, webdav_locks):
await webdav_locks.acquire_lock("/file1.txt", "user1", user_id=1)
await webdav_locks.acquire_lock("/file2.txt", "user1", user_id=1)
await webdav_locks.acquire_lock("/file3.txt", "user2", user_id=2)
user1_locks = await webdav_locks.get_user_locks(1)
assert len(user1_locks) == 2
user2_locks = await webdav_locks.get_user_locks(2)
assert len(user2_locks) == 1
@pytest.mark.asyncio
async def test_get_lock_by_token(self, webdav_locks):
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
lock_info = await webdav_locks.get_lock_by_token(token)
assert lock_info is not None
assert lock_info.path == "test/file.txt"
@pytest.mark.asyncio
async def test_expired_lock_cleanup(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1, timeout=0)
await asyncio.sleep(0.1)
lock_info = await webdav_locks.check_lock("/test/file.txt")
assert lock_info is None
@pytest.mark.asyncio
async def test_stats(self, webdav_locks):
await webdav_locks.acquire_lock("/file1.txt", "user1", user_id=1)
await webdav_locks.acquire_lock("/file2.txt", "user1", user_id=1)
stats = await webdav_locks.get_stats()
assert stats["total_locks"] == 2
assert stats["active_locks"] == 2
@pytest.mark.asyncio
async def test_path_normalization(self, webdav_locks):
token1 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
token2 = await webdav_locks.acquire_lock("test/file.txt", "user1", user_id=1)
token3 = await webdav_locks.acquire_lock("/test/file.txt/", "user1", user_id=1)
assert token1 == token2 == token3
+72
View File
@@ -0,0 +1,72 @@
import pytest
from httpx import AsyncClient, ASGITransport
from fastapi import status
from mywebdav.main import app
from mywebdav.models import User, Folder, File, Share
from mywebdav.auth import get_password_hash
import secrets
from datetime import datetime, timedelta
@pytest.mark.asyncio
async def test_share_subfolder_navigation():
# 1. Setup: User, Parent Folder, Subfolder, File in Subfolder
user = await User.create(
username="shareuser",
email="share@example.com",
hashed_password=get_password_hash("testpass"),
is_active=True
)
parent_folder = await Folder.create(name="parent", owner=user)
subfolder = await Folder.create(name="subfolder", parent=parent_folder, owner=user)
file_in_sub = await File.create(
name="deep_file.txt",
path="parent/subfolder/deep_file.txt",
size=10,
mime_type="text/plain",
file_hash="hash",
owner=user,
parent=subfolder
)
# 2. Create Share for Parent Folder
token = secrets.token_urlsafe(16)
share = await Share.create(
token=token,
folder=parent_folder,
owner=user,
permission_level="viewer"
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
# 3. Access Share Root
resp = await client.post(f"/shares/{token}/access")
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "folder"
assert data["folder"]["id"] == parent_folder.id
# Should see subfolder
assert any(f["id"] == subfolder.id for f in data["folders"])
# 4. Try to Access Subfolder (Expecting this to fail or return root currently)
# We'll try passing subfolder_id as a query param, which is a common pattern
resp_sub = await client.post(f"/shares/{token}/access?subfolder_id={subfolder.id}")
# If the feature is missing, this might just ignore the param and return root,
# or fail if the param isn't expected.
# We WANT it to return the subfolder content.
assert resp_sub.status_code == 200
sub_data = resp_sub.json()
# This assertion will fail if the feature is not implemented (it will likely return parent folder again)
assert sub_data["folder"]["id"] == subfolder.id
# Should see the file inside
assert any(f["id"] == file_in_sub.id for f in sub_data["files"])
# Cleanup
await file_in_sub.delete()
await subfolder.delete()
await share.delete()
await parent_folder.delete()
await user.delete()