feat: replace sequential IDs with opaque tokens in public URLs

Sequential IDs in /planner/export/<id> and /leads/<id>/unlock leaked
business volume (e.g. export_id=47 reveals ~47 PDFs sold). Replace with
22-char URL-safe tokens that carry no countable information.

- Migration 0017: adds `token TEXT` to business_plan_exports and
  lead_requests, backfills existing rows with secrets.token_urlsafe(16),
  creates unique indexes for fast lookups
- billing/routes.py: INSERT into business_plan_exports includes token
- leads/routes.py: INSERT into lead_requests includes token; enqueue
  payload includes lead_token; verify_quote() looks up by token
- planner/routes.py: /export/<token> route (was /export/<int:export_id>)
- suppliers/routes.py: /leads/<token>/unlock (was /leads/<int:lead_id>)
- worker.py: email links use token for both export and verify URLs
- Templates: url_for() calls use token= param
- test_phase0.py: _submit_guest_quote() returns (lead_id, auth_token,
  lead_token); verify URL tests use opaque lead token

Integer PKs unchanged; admin routes unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-22 17:03:13 +01:00
parent 35fe934fec
commit 815fb7d98a
11 changed files with 103 additions and 38 deletions

View File

@@ -514,7 +514,7 @@ class TestQuoteVerification:
}
async def _submit_guest_quote(self, client, db, email="verify@example.com"):
"""Helper: submit a quote as a guest, return (lead_id, token)."""
"""Helper: submit a quote as a guest, return (lead_id, auth_token, lead_token)."""
await client.get("/en/leads/quote")
async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "")
@@ -522,20 +522,21 @@ class TestQuoteVerification:
await client.post("/en/leads/quote", form=form)
async with db.execute(
"SELECT id FROM lead_requests WHERE contact_email = ?", (email,)
"SELECT id, token FROM lead_requests WHERE contact_email = ?", (email,)
) as cur:
lead_id = (await cur.fetchone())[0]
row = await cur.fetchone()
lead_id, lead_token = row[0], row[1]
async with db.execute(
"SELECT token FROM auth_tokens ORDER BY id DESC LIMIT 1"
) as cur:
token = (await cur.fetchone())[0]
auth_token = (await cur.fetchone())[0]
return lead_id, token
return lead_id, auth_token, lead_token
async def test_guest_quote_creates_pending_lead(self, client, db):
"""Guest quote creates lead with status='pending_verification'."""
lead_id, _ = await self._submit_guest_quote(client, db)
lead_id, _, _lt = await self._submit_guest_quote(client, db)
async with db.execute(
"SELECT status FROM lead_requests WHERE id = ?", (lead_id,)
@@ -593,9 +594,9 @@ class TestQuoteVerification:
async def test_verify_link_activates_lead(self, client, db):
"""GET /leads/verify with valid token sets status='new' and verified_at."""
lead_id, token = await self._submit_guest_quote(client, db)
lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db)
resp = await client.get(f"/en/leads/verify?token={token}&lead={lead_id}")
resp = await client.get(f"/en/leads/verify?token={auth_token}&lead={lead_token}")
assert resp.status_code == 200
async with db.execute(
@@ -607,30 +608,30 @@ class TestQuoteVerification:
async def test_verify_sets_session(self, client, db):
"""Verification link logs the user in (sets session user_id)."""
lead_id, token = await self._submit_guest_quote(client, db)
_lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db)
await client.get(f"/en/leads/verify?token={token}&lead={lead_id}")
await client.get(f"/en/leads/verify?token={auth_token}&lead={lead_token}")
async with client.session_transaction() as sess:
assert "user_id" in sess
async def test_verify_expired_token(self, client, db):
"""Expired/used token redirects with error."""
lead_id, token = await self._submit_guest_quote(client, db)
_lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db)
# Expire the token
await db.execute("UPDATE auth_tokens SET expires_at = '2000-01-01T00:00:00'")
await db.commit()
resp = await client.get(
f"/en/leads/verify?token={token}&lead={lead_id}",
f"/en/leads/verify?token={auth_token}&lead={lead_token}",
follow_redirects=False,
)
assert resp.status_code == 302
async def test_verify_already_verified_lead(self, client, db):
"""Attempting to verify an already-activated lead shows error."""
lead_id, token = await self._submit_guest_quote(client, db)
lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db)
# Manually activate the lead
await db.execute(
@@ -639,7 +640,7 @@ class TestQuoteVerification:
await db.commit()
resp = await client.get(
f"/en/leads/verify?token={token}&lead={lead_id}",
f"/en/leads/verify?token={auth_token}&lead={lead_token}",
follow_redirects=False,
)
assert resp.status_code == 302