diff --git a/docs/architecture/license/portal.md b/docs/architecture/license/portal.md index 583cea7d2..61fcd57a8 100644 --- a/docs/architecture/license/portal.md +++ b/docs/architecture/license/portal.md @@ -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. diff --git a/surfsense_backend/app/license/records.py b/surfsense_backend/app/license/records.py index 2abd5f4c7..075259175 100644 --- a/surfsense_backend/app/license/records.py +++ b/surfsense_backend/app/license/records.py @@ -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 ""), diff --git a/surfsense_backend/scripts/correct_license_email.py b/surfsense_backend/scripts/correct_license_email.py index cf6c169a8..2f446face 100644 --- a/surfsense_backend/scripts/correct_license_email.py +++ b/surfsense_backend/scripts/correct_license_email.py @@ -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 diff --git a/surfsense_backend/tests/unit/license/test_trial_correction_lookup.py b/surfsense_backend/tests/unit/license/test_trial_correction_lookup.py new file mode 100644 index 000000000..4d3c14252 --- /dev/null +++ b/surfsense_backend/tests/unit/license/test_trial_correction_lookup.py @@ -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")