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:
Roberto Musso
2026-04-11 19:41:27 +02:00
parent 5f79ce87f9
commit 352e25d651
6 changed files with 232 additions and 14 deletions

View File

@@ -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