feat: GDPR compliance — anonymization, unsubscribe, consent tracking
- Add consent_given_at and anonymized_at fields + Alembic migration (002) - Add GET /waitlist/unsubscribe endpoint (HMAC token, anonymizes PII) - Add cleanup.py: cron-able script to anonymize unconfirmed entries after 48h - Clear IP address on email confirmation (no longer needed) - Add unsubscribe link in confirmation email footer - Record consent timestamp on signup - Add 4 new tests (unsubscribe, consent timestamp) - Update .env.example, schemas
This commit is contained in:
@@ -153,9 +153,9 @@ async def test_token_tampered():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_valid_token(client, db_session):
|
||||
"""GET /confirm with valid token marks email as confirmed."""
|
||||
"""GET /confirm with valid token marks email as confirmed and clears IP."""
|
||||
# Seed an unconfirmed entry
|
||||
entry = WaitlistEntry(email="confirm@example.com", source="website")
|
||||
entry = WaitlistEntry(email="confirm@example.com", source="website", ip_address="1.2.3.4")
|
||||
db_session.add(entry)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -168,7 +168,9 @@ async def test_confirm_valid_token(client, db_session):
|
||||
result = await db_session.execute(
|
||||
select(WaitlistEntry).where(WaitlistEntry.email == "confirm@example.com")
|
||||
)
|
||||
assert result.scalar_one().confirmed is True
|
||||
confirmed_entry = result.scalar_one()
|
||||
assert confirmed_entry.confirmed is True
|
||||
assert confirmed_entry.ip_address is None # IP cleared on confirm
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -224,3 +226,64 @@ async def test_signup_triggers_confirmation_email(client, db_session):
|
||||
call_args = mock_send.call_args
|
||||
assert call_args[0][0] == "brevo@example.com"
|
||||
assert "confirm" in call_args[0][1]
|
||||
|
||||
|
||||
# ── Unsubscribe / anonymize tests ────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_anonymizes_entry(client, db_session):
|
||||
"""GET /unsubscribe with valid token anonymizes the entry."""
|
||||
entry = WaitlistEntry(email="unsub@example.com", source="website", confirmed=True)
|
||||
db_session.add(entry)
|
||||
await db_session.commit()
|
||||
entry_id = entry.id
|
||||
|
||||
token = generate_token("unsub@example.com")
|
||||
resp = await client.get(f"/api/v1/waitlist/unsubscribe?token={token}")
|
||||
assert resp.status_code == 200
|
||||
assert "removed" in resp.text.lower() or "anonymized" in resp.text.lower()
|
||||
|
||||
# Verify anonymization
|
||||
result = await db_session.execute(
|
||||
select(WaitlistEntry).where(WaitlistEntry.id == entry_id)
|
||||
)
|
||||
anon = result.scalar_one()
|
||||
assert anon.email == f"anon-{entry_id}@removed.invalid"
|
||||
assert anon.ip_address is None
|
||||
assert anon.consent_given_at is None
|
||||
assert anon.anonymized_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_invalid_token(client):
|
||||
"""GET /unsubscribe with invalid token returns 400."""
|
||||
resp = await client.get("/api/v1/waitlist/unsubscribe?token=garbage")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_already_gone(client):
|
||||
"""GET /unsubscribe for non-existent entry returns 200 (idempotent)."""
|
||||
token = generate_token("gone@example.com")
|
||||
resp = await client.get(f"/api/v1/waitlist/unsubscribe?token={token}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── Consent timestamp tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signup_records_consent_timestamp(client, db_session):
|
||||
"""New signup records consent_given_at."""
|
||||
resp = await client.post(
|
||||
"/api/v1/waitlist",
|
||||
json={"email": "consent@example.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
result = await db_session.execute(
|
||||
select(WaitlistEntry).where(WaitlistEntry.email == "consent@example.com")
|
||||
)
|
||||
entry = result.scalar_one()
|
||||
assert entry.consent_given_at is not None
|
||||
|
||||
Reference in New Issue
Block a user