feat: 实现PIPL数据主体删除权支持功能
- 新增 LeadDeletionRequest 模型,支持邮箱验证的删除申请/确认流程 - POST /api/v1/custom/leads/deletion-requests/ 发起申请(通用响应避免探测邮箱是否存在) - POST /api/v1/custom/leads/deletion-requests/confirm/ 确认删除(24小时token有效期) - 按邮箱在 Lead.data 中匹配并删除对应记录,跨SQLite/Postgres使用Python级匹配 - LeadDeletionRequest 通过 Django Admin 只读展示供合规审计 - 新增 FRONTEND_BASE_URL 设置用于拼接邮件确认链接 - apps/forms/tests.py 新增6个测试用例(含节流缓存隔离修复),全仓库30个用例通过 - 更新设计文档 §2.15/§2.16 勾选项
This commit is contained in:
+138
-1
@@ -1,12 +1,28 @@
|
||||
"""apps.forms 单元测试:线索提交 API(B2B 官网核心转化路径,测试覆盖率要求较高)。"""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.forms.models import FormDefinition, FormDefinitionField, Lead
|
||||
from apps.forms.models import (
|
||||
FormDefinition,
|
||||
FormDefinitionField,
|
||||
Lead,
|
||||
LeadDeletionRequest,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_throttle_cache():
|
||||
"""ScopedRateThrottle 依赖默认缓存记录请求次数,避免测试间相互影响(forms scope 限流 5/min)。"""
|
||||
cache.clear()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def contact_form():
|
||||
form_def = FormDefinition.objects.create(
|
||||
@@ -62,3 +78,124 @@ def test_lead_submit_requires_form_id():
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# --- PIPL 数据删除权(详见设计文档 §2.15) ---
|
||||
|
||||
|
||||
def test_deletion_request_returns_generic_message_and_sends_email(
|
||||
contact_form, mailoutbox
|
||||
):
|
||||
Lead.objects.create(
|
||||
form=contact_form,
|
||||
data={"name": "张三", "email": "zhangsan@example.com"},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1/custom/leads/deletion-requests/",
|
||||
{"contact": "zhangsan@example.com"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert LeadDeletionRequest.objects.filter(contact="zhangsan@example.com").exists()
|
||||
assert len(mailoutbox) == 1
|
||||
assert mailoutbox[0].to == ["zhangsan@example.com"]
|
||||
|
||||
|
||||
def test_deletion_request_returns_same_generic_message_for_unknown_contact(mailoutbox):
|
||||
"""未提交过表单的邮箱也返回相同提示,避免探测。"""
|
||||
client = APIClient()
|
||||
response_known = client.post(
|
||||
"/api/v1/custom/leads/deletion-requests/",
|
||||
{"contact": "known@example.com"},
|
||||
format="json",
|
||||
)
|
||||
response_unknown = client.post(
|
||||
"/api/v1/custom/leads/deletion-requests/",
|
||||
{"contact": "unknown@example.com"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response_known.status_code == response_unknown.status_code == 202
|
||||
assert response_known.data["message"] == response_unknown.data["message"]
|
||||
|
||||
|
||||
def test_deletion_confirm_deletes_matching_leads_only(contact_form):
|
||||
matching_lead = Lead.objects.create(
|
||||
form=contact_form,
|
||||
data={"name": "张三", "email": "zhangsan@example.com"},
|
||||
)
|
||||
other_lead = Lead.objects.create(
|
||||
form=contact_form,
|
||||
data={"name": "李四", "email": "lisi@example.com"},
|
||||
)
|
||||
deletion_request = LeadDeletionRequest.objects.create(contact="zhangsan@example.com")
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1/custom/leads/deletion-requests/confirm/",
|
||||
{"token": str(deletion_request.token)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["success"] is True
|
||||
assert response.data["deleted_count"] == 1
|
||||
assert not Lead.objects.filter(pk=matching_lead.pk).exists()
|
||||
assert Lead.objects.filter(pk=other_lead.pk).exists()
|
||||
|
||||
deletion_request.refresh_from_db()
|
||||
assert deletion_request.status == LeadDeletionRequest.STATUS_COMPLETED
|
||||
assert deletion_request.deleted_count == 1
|
||||
assert deletion_request.completed_at is not None
|
||||
|
||||
|
||||
def test_deletion_confirm_invalid_token_returns_404():
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1/custom/leads/deletion-requests/confirm/",
|
||||
{"token": "00000000-0000-0000-0000-000000000000"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.data["error"]["code"] == "INVALID_TOKEN"
|
||||
|
||||
|
||||
def test_deletion_confirm_expired_token_returns_400():
|
||||
deletion_request = LeadDeletionRequest.objects.create(contact="zhangsan@example.com")
|
||||
LeadDeletionRequest.objects.filter(pk=deletion_request.pk).update(
|
||||
requested_at=timezone.now() - timedelta(hours=25)
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1/custom/leads/deletion-requests/confirm/",
|
||||
{"token": str(deletion_request.token)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.data["error"]["code"] == "TOKEN_EXPIRED"
|
||||
|
||||
deletion_request.refresh_from_db()
|
||||
assert deletion_request.status == LeadDeletionRequest.STATUS_EXPIRED
|
||||
|
||||
|
||||
def test_deletion_confirm_already_processed_returns_400():
|
||||
deletion_request = LeadDeletionRequest.objects.create(
|
||||
contact="zhangsan@example.com",
|
||||
status=LeadDeletionRequest.STATUS_COMPLETED,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1/custom/leads/deletion-requests/confirm/",
|
||||
{"token": str(deletion_request.token)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.data["error"]["code"] == "ALREADY_PROCESSED"
|
||||
|
||||
Reference in New Issue
Block a user