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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user