feat: implement WebDAV protocol support with WsgiDAV + account management API
All checks were successful
Test / test (push) Successful in 2m14s
All checks were successful
Test / test (push) Successful in 2m14s
Add complete WebDAV support: management REST API (CRUD accounts at /api/v1/webdav/accounts) and DAV protocol endpoint (/dav) using WsgiDAV + a2wsgi bridge for client access via HTTP Basic Auth. Includes Redis+TTLCache auth caching and integration tests (24 cases). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
591
tests/integration/api/test_webdav.py
Normal file
591
tests/integration/api/test_webdav.py
Normal file
@@ -0,0 +1,591 @@
|
||||
"""
|
||||
WebDAV 账户管理端点集成测试
|
||||
"""
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from sqlmodels import Group, GroupClaims, GroupOptions, Object, ObjectType, User
|
||||
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
|
||||
from sqlmodels.user import UserStatus
|
||||
from utils import Password
|
||||
from utils.JWT import create_access_token
|
||||
|
||||
API_PREFIX = "/api/v1/webdav"
|
||||
|
||||
|
||||
# ==================== Fixtures ====================
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def no_webdav_headers(initialized_db: AsyncSession) -> dict[str, str]:
|
||||
"""创建一个 WebDAV 被禁用的用户,返回其认证头"""
|
||||
group = Group(
|
||||
id=uuid4(),
|
||||
name="无WebDAV用户组",
|
||||
max_storage=1024 * 1024 * 1024,
|
||||
share_enabled=True,
|
||||
web_dav_enabled=False,
|
||||
admin=False,
|
||||
speed_limit=0,
|
||||
)
|
||||
initialized_db.add(group)
|
||||
await initialized_db.commit()
|
||||
await initialized_db.refresh(group)
|
||||
|
||||
group_options = GroupOptions(
|
||||
group_id=group.id,
|
||||
share_download=True,
|
||||
share_free=False,
|
||||
relocate=False,
|
||||
source_batch=0,
|
||||
select_node=False,
|
||||
advance_delete=False,
|
||||
)
|
||||
initialized_db.add(group_options)
|
||||
await initialized_db.commit()
|
||||
await initialized_db.refresh(group_options)
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="nowebdav@test.local",
|
||||
nickname="无WebDAV用户",
|
||||
status=UserStatus.ACTIVE,
|
||||
storage=0,
|
||||
score=0,
|
||||
group_id=group.id,
|
||||
avatar="default",
|
||||
)
|
||||
initialized_db.add(user)
|
||||
await initialized_db.commit()
|
||||
await initialized_db.refresh(user)
|
||||
|
||||
identity = AuthIdentity(
|
||||
provider=AuthProviderType.EMAIL_PASSWORD,
|
||||
identifier="nowebdav@test.local",
|
||||
credential=Password.hash("nowebdav123"),
|
||||
is_primary=True,
|
||||
is_verified=True,
|
||||
user_id=user.id,
|
||||
)
|
||||
initialized_db.add(identity)
|
||||
|
||||
from sqlmodels import Policy
|
||||
policy = await Policy.get(initialized_db, Policy.name == "本地存储")
|
||||
|
||||
root = Object(
|
||||
id=uuid4(),
|
||||
name="/",
|
||||
type=ObjectType.FOLDER,
|
||||
owner_id=user.id,
|
||||
parent_id=None,
|
||||
policy_id=policy.id,
|
||||
size=0,
|
||||
)
|
||||
initialized_db.add(root)
|
||||
await initialized_db.commit()
|
||||
|
||||
group.options = group_options
|
||||
group_claims = GroupClaims.from_group(group)
|
||||
result = create_access_token(
|
||||
sub=user.id,
|
||||
jti=uuid4(),
|
||||
status=user.status.value,
|
||||
group=group_claims,
|
||||
)
|
||||
return {"Authorization": f"Bearer {result.access_token}"}
|
||||
|
||||
|
||||
# ==================== 认证测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_requires_auth(async_client: AsyncClient):
|
||||
"""测试获取账户列表需要认证"""
|
||||
response = await async_client.get(f"{API_PREFIX}/accounts")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_requires_auth(async_client: AsyncClient):
|
||||
"""测试创建账户需要认证"""
|
||||
response = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
json={"name": "test", "password": "testpass"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_requires_auth(async_client: AsyncClient):
|
||||
"""测试更新账户需要认证"""
|
||||
response = await async_client.patch(
|
||||
f"{API_PREFIX}/accounts/1",
|
||||
json={"readonly": True},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_account_requires_auth(async_client: AsyncClient):
|
||||
"""测试删除账户需要认证"""
|
||||
response = await async_client.delete(f"{API_PREFIX}/accounts/1")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ==================== WebDAV 禁用测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_webdav_disabled(
|
||||
async_client: AsyncClient,
|
||||
no_webdav_headers: dict[str, str],
|
||||
):
|
||||
"""测试 WebDAV 被禁用时返回 403"""
|
||||
response = await async_client.get(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=no_webdav_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_webdav_disabled(
|
||||
async_client: AsyncClient,
|
||||
no_webdav_headers: dict[str, str],
|
||||
):
|
||||
"""测试 WebDAV 被禁用时创建账户返回 403"""
|
||||
response = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=no_webdav_headers,
|
||||
json={"name": "test", "password": "testpass"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ==================== 获取账户列表测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_empty(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试初始状态账户列表为空"""
|
||||
response = await async_client.get(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
# ==================== 创建账户测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_success(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试成功创建 WebDAV 账户"""
|
||||
response = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": "my-nas", "password": "secretpass"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
data = response.json()
|
||||
assert data["name"] == "my-nas"
|
||||
assert data["root"] == "/"
|
||||
assert data["readonly"] is False
|
||||
assert data["use_proxy"] is False
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
assert "updated_at" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_with_options(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试创建带选项的 WebDAV 账户"""
|
||||
response = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "readonly-nas",
|
||||
"password": "secretpass",
|
||||
"readonly": True,
|
||||
"use_proxy": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
data = response.json()
|
||||
assert data["name"] == "readonly-nas"
|
||||
assert data["readonly"] is True
|
||||
assert data["use_proxy"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_duplicate_name(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试重名账户返回 409"""
|
||||
# 先创建一个
|
||||
response = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": "dup-test", "password": "pass1"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
# 再创建同名的
|
||||
response = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": "dup-test", "password": "pass2"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_invalid_root(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试无效根目录路径返回 400"""
|
||||
response = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "bad-root",
|
||||
"password": "secretpass",
|
||||
"root": "/nonexistent/path",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_with_valid_subdir(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
test_directory_structure: dict[str, UUID],
|
||||
):
|
||||
"""测试使用有效的子目录作为根路径"""
|
||||
response = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "docs-only",
|
||||
"password": "secretpass",
|
||||
"root": "/docs",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["root"] == "/docs"
|
||||
|
||||
|
||||
# ==================== 列表包含已创建账户测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_after_create(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试创建后列表中包含该账户"""
|
||||
# 创建
|
||||
await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": "list-test", "password": "pass"},
|
||||
)
|
||||
|
||||
# 列表
|
||||
response = await async_client.get(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
accounts = response.json()
|
||||
assert len(accounts) == 1
|
||||
assert accounts[0]["name"] == "list-test"
|
||||
|
||||
|
||||
# ==================== 更新账户测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_success(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试成功更新 WebDAV 账户"""
|
||||
# 创建
|
||||
create_resp = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": "update-test", "password": "oldpass"},
|
||||
)
|
||||
account_id = create_resp.json()["id"]
|
||||
|
||||
# 更新
|
||||
response = await async_client.patch(
|
||||
f"{API_PREFIX}/accounts/{account_id}",
|
||||
headers=auth_headers,
|
||||
json={"readonly": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["readonly"] is True
|
||||
assert data["name"] == "update-test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_password(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试更新密码"""
|
||||
# 创建
|
||||
create_resp = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": "pwd-test", "password": "oldpass"},
|
||||
)
|
||||
account_id = create_resp.json()["id"]
|
||||
|
||||
# 更新密码
|
||||
response = await async_client.patch(
|
||||
f"{API_PREFIX}/accounts/{account_id}",
|
||||
headers=auth_headers,
|
||||
json={"password": "newpass123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_root(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
test_directory_structure: dict[str, UUID],
|
||||
):
|
||||
"""测试更新根目录路径"""
|
||||
# 创建
|
||||
create_resp = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": "root-update", "password": "pass"},
|
||||
)
|
||||
account_id = create_resp.json()["id"]
|
||||
|
||||
# 更新 root 到有效子目录
|
||||
response = await async_client.patch(
|
||||
f"{API_PREFIX}/accounts/{account_id}",
|
||||
headers=auth_headers,
|
||||
json={"root": "/docs"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["root"] == "/docs"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_invalid_root(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试更新为无效根目录返回 400"""
|
||||
# 创建
|
||||
create_resp = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": "bad-root-update", "password": "pass"},
|
||||
)
|
||||
account_id = create_resp.json()["id"]
|
||||
|
||||
# 更新到无效路径
|
||||
response = await async_client.patch(
|
||||
f"{API_PREFIX}/accounts/{account_id}",
|
||||
headers=auth_headers,
|
||||
json={"root": "/nonexistent"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_not_found(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试更新不存在的账户返回 404"""
|
||||
response = await async_client.patch(
|
||||
f"{API_PREFIX}/accounts/99999",
|
||||
headers=auth_headers,
|
||||
json={"readonly": True},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_other_user_account(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
admin_headers: dict[str, str],
|
||||
):
|
||||
"""测试更新其他用户的账户返回 404"""
|
||||
# 管理员创建账户
|
||||
create_resp = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=admin_headers,
|
||||
json={"name": "admin-account", "password": "pass"},
|
||||
)
|
||||
account_id = create_resp.json()["id"]
|
||||
|
||||
# 普通用户尝试更新
|
||||
response = await async_client.patch(
|
||||
f"{API_PREFIX}/accounts/{account_id}",
|
||||
headers=auth_headers,
|
||||
json={"readonly": True},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ==================== 删除账户测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_account_success(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试成功删除 WebDAV 账户"""
|
||||
# 创建
|
||||
create_resp = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": "delete-test", "password": "pass"},
|
||||
)
|
||||
account_id = create_resp.json()["id"]
|
||||
|
||||
# 删除
|
||||
response = await async_client.delete(
|
||||
f"{API_PREFIX}/accounts/{account_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# 确认列表中已不存在
|
||||
list_resp = await async_client.get(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert list_resp.status_code == 200
|
||||
names = [a["name"] for a in list_resp.json()]
|
||||
assert "delete-test" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_account_not_found(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试删除不存在的账户返回 404"""
|
||||
response = await async_client.delete(
|
||||
f"{API_PREFIX}/accounts/99999",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_other_user_account(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
admin_headers: dict[str, str],
|
||||
):
|
||||
"""测试删除其他用户的账户返回 404"""
|
||||
# 管理员创建账户
|
||||
create_resp = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=admin_headers,
|
||||
json={"name": "admin-del-test", "password": "pass"},
|
||||
)
|
||||
account_id = create_resp.json()["id"]
|
||||
|
||||
# 普通用户尝试删除
|
||||
response = await async_client.delete(
|
||||
f"{API_PREFIX}/accounts/{account_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ==================== 多账户测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_accounts(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试同一用户可以创建多个账户"""
|
||||
for name in ["account-1", "account-2", "account-3"]:
|
||||
response = await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": name, "password": "pass"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
# 列表应有3个
|
||||
response = await async_client.get(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 3
|
||||
|
||||
|
||||
# ==================== 用户隔离测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accounts_user_isolation(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
admin_headers: dict[str, str],
|
||||
):
|
||||
"""测试不同用户的账户相互隔离"""
|
||||
# 普通用户创建
|
||||
await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
json={"name": "user-account", "password": "pass"},
|
||||
)
|
||||
|
||||
# 管理员创建
|
||||
await async_client.post(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=admin_headers,
|
||||
json={"name": "admin-account", "password": "pass"},
|
||||
)
|
||||
|
||||
# 普通用户只看到自己的
|
||||
response = await async_client.get(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
accounts = response.json()
|
||||
assert len(accounts) == 1
|
||||
assert accounts[0]["name"] == "user-account"
|
||||
|
||||
# 管理员只看到自己的
|
||||
response = await async_client.get(
|
||||
f"{API_PREFIX}/accounts",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
accounts = response.json()
|
||||
assert len(accounts) == 1
|
||||
assert accounts[0]["name"] == "admin-account"
|
||||
Reference in New Issue
Block a user