mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-10-19 07:34:58 +00:00
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:
commit
de9091d6c3
4 changed files with 127 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ""),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue