Merge pull request #2086 from Cedric921/fix/correct-trial-license-email

fix(license): let support correct a trial's address
This commit is contained in:
Rohan Verma 2026-09-30 16:10:02 -07:00 • committed by GitHub
commit de9091d6c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 127 additions and 8 deletions

View file

@ -161,12 +161,13 @@ python -m scripts.correct_license_email --session cs_... --email real@buyer.com
The script matches on the payment, never on how alike two addresses look: `checkoutSessionId` names exactly one license, and the script refuses when two carry it. It prints the record, asks for confirmation unless `--yes` is passed, rewrites the stored `email`, and then mails a fresh file with `--mail` or prints it. Rewriting the address is the point, because resend looks customers up by it; a typo left in Keygen makes every future download another ticket. A buyer who lost the inbox takes the same path. Someone who fools support gets one license that can be suspended, a risk accepted over identity checks.
A trial has no payment to prove, so the anchor is the address itself: support asks which exact address the trial was requested with and runs `--trial-email typo@... --email real@... --mail` in place of `--session`. The lookup filters on that address as typed and on its folded `trialKey`, so it finds only a trial issued to exactly that address, never a lookalike and never a paid licence there, and it refuses when two match. Someone who knows another person's exact address could have that trial repointed to their own inbox. They gain a free trial that expires and can be suspended, and the correction moves `trialKey` with the address, so the owner can claim a trial again; that risk is accepted as for purchases.
## Tests
`tests/unit/license/test_issue.py` covers Keygen payload shapes, metadata filter syntax, the CE headers, trial expiry, address folding, plan resolution, derived ids and both duplicate-id answers; `test_message.py` and `test_release.py` cover the mail and the release lookup; `test_mail_guard.py` covers the mail check both POST routes run before Keygen. `tests/unit/mailer/` covers the SMTP error mapping, MIME shape, senders and configuration checks, and `tests/integration/mailer/test_smtp_contract.py` sends through a real SMTP conversation against Mailpit when `SMTP_INTEGRATION=1`. The route and rate-limit suites, and the Keygen and mailer fakes they stood on, were deleted on 15 Sep 2026 in commit `7f1195c76`.
`tests/unit/license/test_issue.py` covers Keygen payload shapes, metadata filter syntax, the CE headers, trial expiry, address folding, plan resolution, derived ids and both duplicate-id answers; `test_message.py` and `test_release.py` cover the mail and the release lookup; `test_mail_guard.py` covers the mail check both POST routes run before Keygen; `test_trial_correction_lookup.py` covers the support lookup of a trial by its exact address. `tests/unit/mailer/` covers the SMTP error mapping, MIME shape, senders and configuration checks, and `tests/integration/mailer/test_smtp_contract.py` sends through a real SMTP conversation against Mailpit when `SMTP_INTEGRATION=1`. The route and rate-limit suites, and the Keygen and mailer fakes they stood on, were deleted on 15 Sep 2026 in commit `7f1195c76`.
## Known gaps
- A refund suspends every license the Stripe customer holds, partial refunds included.
- No test covers the license routes beyond their mail check, or their rate limits, and no contract test runs the Keygen client against a real Keygen.
- `correct_license_email.py` finds a license only by `--session`, so a trial's address cannot be corrected through it.

View file

@ -8,12 +8,13 @@ from __future__ import annotations
from typing import Any, cast
from app.license import keygen
from app.license.email.address import normalize_email
from app.license.email.address import fold_email, normalize_email
from app.license.keygen import LicensePlan
from app.license.models import (
META_EMAIL,
META_PLAN,
META_SESSION,
META_TRIAL_KEY,
IssuedLicense,
LicenseNotFoundError,
LicenseRecord,
@ -99,7 +100,32 @@ async def find_license_by_checkout_session(session_id: str) -> LicenseRecord:
f"{len(matches)} licenses carry checkoutSessionId {session_id!r}; "
"resolve the duplicate in Keygen before correcting it"
)
record = matches[0]
return _record(matches[0])
async def find_trial_license_by_email(email: str) -> LicenseRecord:
"""Resolve the trial issued to exactly this address, for a support correction.
A trial has no payment to prove, so the anchor is the address as it was
typed: support acts only on the exact address the requester says they
entered, never on one that merely looks like it. Filtering on the trial key
as well keeps a paid licence at that address out of reach.
"""
typed = normalize_email(email)
matches = await keygen.list_licenses(
metadata={META_EMAIL: typed, META_TRIAL_KEY: fold_email(typed)}, limit=2
)
if not matches:
raise LicenseNotFoundError(f"No trial license was issued to {typed!r}")
if len(matches) > 1:
raise LicenseNotFoundError(
f"{len(matches)} trial licenses were issued to {typed!r}; "
"resolve the duplicate in Keygen before correcting it"
)
return _record(matches[0])
def _record(record: dict[str, Any]) -> LicenseRecord:
attributes = record.get("attributes") or {}
return LicenseRecord(
keygen_license_id=str(record.get("id") or ""),

View file

@ -9,12 +9,20 @@ Support matches on the **payment**, never on how similar two addresses look:
ask the buyer for the charge id (or last 4 + amount + date), find it in Stripe,
and use that checkout session id here.
A trial has no payment, so ``--trial-email`` takes the exact address the trial
was issued to, as the requester says they typed it. Never guess it from a
lookalike: ask again. Someone who knows another person's exact address could
have that trial repointed to them; they gain only a free, expiring, suspendable
trial, and the correction moves the trial key too, so the owner can claim again.
Correcting the stored address matters as much as sending the file. Leave the
typo in Keygen and every future re-download is another support ticket, because
``/license/resend`` looks the customer up by that address.
python -m scripts.correct_license_email --session cs_test_123 \
--email real@buyer.com --mail
python -m scripts.correct_license_email --trial-email typo@exmaple.com \
--email real@example.com --mail
Prints the current record and asks for confirmation before writing, unless
--yes is passed.
@ -29,16 +37,23 @@ import sys
from app.license.admin import correct_license_email
from app.license.email.deliver import deliver_licenses
from app.license.models import LicenseNotFoundError
from app.license.records import find_license_by_checkout_session
from app.license.records import (
find_license_by_checkout_session,
find_trial_license_by_email,
)
async def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
anchor = parser.add_mutually_exclusive_group(required=True)
anchor.add_argument(
"--session",
required=True,
help="Stripe checkout session id, from the payment in the Stripe dashboard",
)
anchor.add_argument(
"--trial-email",
help="The exact address a trial was issued to, as the requester typed it",
)
parser.add_argument("--email", required=True, help="The buyer's real address")
parser.add_argument(
"--mail",
@ -49,7 +64,10 @@ async def main() -> int:
args = parser.parse_args()
try:
record = await find_license_by_checkout_session(args.session)
if args.session is not None:
record = await find_license_by_checkout_session(args.session)
else:
record = await find_trial_license_by_email(args.trial_email)
except LicenseNotFoundError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1

View file

@ -0,0 +1,74 @@
"""Support finds the trial behind a mistyped address, and only that one.
A trial has no payment to prove, so the anchor is the exact address the trial
was issued to. The lookup must not match on a lookalike, and must refuse rather
than choose when two licences answer.
"""
from __future__ import annotations
import pytest
from app.license import keygen
from app.license.models import META_EMAIL, META_TRIAL_KEY, LicenseNotFoundError
from app.license.records import find_trial_license_by_email
pytestmark = pytest.mark.unit
def trial(license_id: str, email: str, trial_key: str) -> dict:
"""A trial licence as Keygen lists it."""
return {
"id": license_id,
"attributes": {
"metadata": {META_EMAIL: email, META_TRIAL_KEY: trial_key},
"maxUsers": None,
"expiry": "2026-10-14T00:00:00Z",
},
}
@pytest.fixture
def keygen_answers(monkeypatch):
"""Keygen's list endpoint, answering with the licences a test sets."""
seen: list[dict] = []
answer: list[dict] = []
async def list_licenses(*, metadata=None, limit=100, **_):
seen.append(dict(metadata or {}))
return answer[:limit]
monkeypatch.setattr(keygen, "list_licenses", list_licenses)
return seen, answer
async def test_a_trial_is_found_by_the_exact_address_it_was_issued_to(keygen_answers):
"""Filtered on the typed address and its trial key, so a paid licence never matches."""
seen, answer = keygen_answers
answer.append(trial("lic-1", "typo+work@example.test", "typo@example.test"))
record = await find_trial_license_by_email(" Typo+Work@Example.test ")
assert record.keygen_license_id == "lic-1"
assert record.metadata[META_EMAIL] == "typo+work@example.test"
assert seen == [
{META_EMAIL: "typo+work@example.test", META_TRIAL_KEY: "typo@example.test"}
]
async def test_two_trials_on_one_address_are_refused(keygen_answers):
"""Picking one would be a guess about whose trial it is."""
_, answer = keygen_answers
answer += [
trial("lic-1", "typo@example.test", "typo@example.test"),
trial("lic-2", "typo@example.test", "typo@example.test"),
]
with pytest.raises(LicenseNotFoundError, match="2 trial licenses"):
await find_trial_license_by_email("typo@example.test")
async def test_an_address_no_trial_was_issued_to_is_not_found(keygen_answers):
"""A lookalike is not a match; support asks again rather than guessing."""
with pytest.raises(LicenseNotFoundError, match="No trial license"):
await find_trial_license_by_email("nobody@example.test")