"""Reply rendering: token substitution, missing-variable reporting, and the money formatting that appears in a letter to a researcher.""" from __future__ import annotations from datetime import UTC, datetime from decimal import Decimal from types import SimpleNamespace import pytest from app.domains.bounty import config_service as cfg from app.domains.bounty import service as svc from app.domains.bounty.rendering import ( KNOWN_TOKENS, build_context, declared_tokens, format_usd, render, ) def _report(**kwargs): base = { "code": "BB-2026-007", "title": "XSS on docs", "affected_system": "docs.pingcap.com", "cve_id": None, "reward_amount_usd": Decimal("100.00"), "reported_at": datetime(2026, 8, 1, tzinfo=UTC), } base.update(kwargs) return SimpleNamespace(**base) def _researcher(**kwargs): base = { "email": "whitehat@example.com", "display_name": "Alex Chen", "handle": None, } base.update(kwargs) return SimpleNamespace(**base) def test_format_usd_drops_trailing_cents(): # "USD 100" is what every real reply the team has sent says; a # generated-looking "USD 100.00" reads like an invoice. assert format_usd(Decimal("100.00")) == "100" assert format_usd(Decimal("1500")) == "1,500" assert format_usd(Decimal("12.50")) == "12.50" assert format_usd(None) == "" def test_render_substitutes_known_tokens(): ctx = build_context( report=_report(), researcher=_researcher(), severity_label="Low", signature={"signer_name": "Shuai Feng", "company_name": "PingCAP"}, ) out = render( subject="Re: report [{report_code}]", body="Dear {researcher_name},\n\nUSD {amount_usd}.\n\n{signer_name}", context=ctx, ) assert out.subject == "Re: report [BB-2026-007]" assert "Dear Alex Chen," in out.body assert "USD 100." in out.body assert out.body.endswith("Shuai Feng") assert out.missing == [] def test_unknown_tokens_are_left_verbatim(): # Researcher mails are full of JSON and code; a brace must never # raise, and a token we do not own must survive round-tripping. ctx = build_context( report=_report(), researcher=_researcher(), severity_label=None, signature={}, ) out = render( subject="s", body='payload {"a": 1} and {not_a_token} and {report_code}', context=ctx, ) assert '{"a": 1}' in out.body assert "{not_a_token}" in out.body assert "BB-2026-007" in out.body def test_missing_hard_token_is_reported(): ctx = build_context( report=_report(reward_amount_usd=None), researcher=_researcher(), severity_label=None, signature={"signer_name": ""}, ) out = render( subject="s", body="a reward of USD {amount_usd}", context=ctx, ) # The console blocks the send on this: telling a researcher they won # "USD " is worse than telling them nothing. assert "amount_usd" in out.missing def test_soft_tokens_do_not_warn_and_greeting_falls_back(): ctx = build_context( report=_report(), researcher=_researcher(display_name=None, handle=None), severity_label=None, signature={}, ) out = render(subject="s", body="Dear {researcher_name},", context=ctx) assert out.body == "Dear submitter," assert out.missing == [] def test_amount_override_previews_without_touching_the_report(): report = _report(reward_amount_usd=Decimal("100")) ctx = build_context( report=report, researcher=_researcher(), severity_label=None, signature={}, amount_usd=Decimal("300"), ) out = render(subject="s", body="USD {amount_usd}", context=ctx) assert "USD 300" in out.body assert report.reward_amount_usd == Decimal("100") def test_empty_substitution_collapses_dangling_blank_lines(): ctx = build_context( report=_report(), researcher=_researcher(), severity_label=None, signature={"signer_name": "", "signer_title": "Security Team"}, ) out = render( subject="s", body="Best regards,\n{signer_name}\n{signer_title}", context=ctx, ) assert "\n\n\n" not in out.body assert out.body == "Best regards,\nSecurity Team" def test_declared_tokens_only_reports_known_ones(): found = declared_tokens("[{report_code}]", "{amount_usd} {bogus}") assert found == ["amount_usd", "report_code"] assert set(found) <= KNOWN_TOKENS @pytest.mark.parametrize("token", sorted(KNOWN_TOKENS)) def test_every_known_token_resolves_to_a_string(token): """A token in the vocabulary that ``build_context`` forgets to fill would render as an empty string forever — and only be noticed by a researcher reading a broken letter.""" ctx = build_context( report=_report(), researcher=_researcher(), severity_label="Low", signature={"signer_name": "S"}, ) assert token in ctx assert isinstance(ctx[token], str) # ── The reward letters ────────────────────────────────────────────────── # The reported symptom was "回复的邮件没有体现钱". The cause was not a # forgotten placeholder: ONE reward template existed, it froze a past # reply's verdict ("low-impact business area", "open source", "relatively # low in business severity") and was pre-selected for every confirmed # report, so anything above low had to be rewritten by hand — and the # amount went missing in the rewrite. def test_every_tier_has_its_own_reward_letter_and_all_state_the_amount(): from app.domains.bounty import catalog as cat by_key = {t["key"]: t for t in cat.REPLY_TEMPLATES} for tier in ("critical", "high", "medium", "low", "informational"): key = f"reward_{tier}" assert key in by_key, f"no reward letter for the {tier} tier" assert "{amount_usd}" in by_key[key]["body"] assert "amount_usd" in by_key[key]["variables"] # "We have paid you" was freehand before. assert "{amount_usd}" in by_key["payment_sent"]["body"] def test_the_frozen_verdict_letter_is_gone_from_the_catalogue(): """Deleting only the DB row is not enough: the config page's "restore builtin template" reads the catalogue, so leaving the entry there would let anyone resurrect it.""" from app.domains.bounty import catalog as cat assert "reward_confirmed" not in cat.TEMPLATE_KEYS assert all(t["key"] != "reward_confirmed" for t in cat.REPLY_TEMPLATES) def test_no_letter_asks_a_researcher_to_email_bank_details(): """Helix stores no banking data by decision. A letter that solicits an account number by email obtains, in plain text in a mailbox, exactly what the platform refuses to hold.""" from app.domains.bounty import catalog as cat for template in cat.REPLY_TEMPLATES: blob = (template["body"] + template["subject"]).lower() for phrase in ("bank account", "account number", "iban", "swift"): assert phrase not in blob, ( f"{template['key']} asks for {phrase!r}" ) def test_no_reward_letter_asserts_an_impact_it_cannot_know(): """The tier is the only claim a templated letter may make about severity. Wording that asserts low impact cannot be pre-selected for a critical finding.""" from app.domains.bounty import catalog as cat banned = ("low-impact", "relatively low", "open source and publicly") for template in cat.REPLY_TEMPLATES: if template["category"] != "reward": continue body = template["body"].lower() for phrase in banned: assert phrase not in body, f"{template['key']} asserts {phrase!r}" def test_confirmed_reports_resolve_their_letter_through_the_severity(): from app.domains.bounty import catalog as cat assert cat.suggested_template("confirmed", "critical") == "reward_critical" assert cat.suggested_template("confirmed", "low") == "reward_low" # Confirmed but ungraded has no honest reward letter: there is no # amount yet, so pre-selecting one would render "USD ." — which is # precisely the bug. assert cat.suggested_template("confirmed", None) == "under_evaluation" assert cat.suggested_template("new", None) == "under_evaluation" assert cat.suggested_template("duplicate", None) == "duplicate_known" def test_a_tier_letter_renders_the_figure_in_the_body(): from app.domains.bounty import catalog as cat letter = next( t for t in cat.REPLY_TEMPLATES if t["key"] == "reward_high" ) out = render( subject=letter["subject"], body=letter["body"], context=build_context( report=_report(code="BB-2026-014", affected_system="www.pingcap.com"), researcher=SimpleNamespace( email="w@example.com", display_name="Alex", handle=None, ), severity_label="High", signature={ "signer_name": "Shuai", "signer_title": "Security Team", "company_name": "PingCAP", "team_name": "Security", "entity_block": "PingCAP, Inc.", }, amount_usd=Decimal("2000"), duplicate_of_code=None, ), ) assert "USD 2,000" in out.body assert "High" in out.body assert "amount_usd" not in out.missing @pytest.mark.asyncio async def test_every_shipped_letter_greets_the_researcher_by_name(db_session): """Seven of the eleven letters opened with the researcher's name and four opened "Dear submitter," / "Dear valued submitter," / "Hello," / "Hi,". One of the four was `under_evaluation` — the acknowledgement that goes to every unanswered report, so the highest-volume letter in the system and the first thing most researchers ever receive. Found by rendering the reply queue and reading the output, which is the only way this class of defect surfaces: the code was correct, the copy was not. """ from app.domains.bounty import catalog named = await svc.create_report( db_session, title="named researcher", researcher_email="named@example.com", researcher_name="Ada Lovelace", actor=None, ) await db_session.flush() for key in catalog.TEMPLATE_KEYS: template = await cfg.get_template(db_session, key) rendered, _ = await svc.preview_reply( db_session, named, template_key=key, amount_override=Decimal("100"), ) first = next( line for line in rendered.body.splitlines() if line.strip() ) assert "Ada Lovelace" in first, ( f"{key} opens {first!r} — an impersonal form letter is exactly " "what makes a bounty programme feel like a black hole, and the " "name is already in the database" ) assert "{" not in first, f"{key} left a token unrendered: {first!r}" assert "researcher_name" in (template.variables or []), ( f"{key} uses the token without declaring it; `variables` is what " "the console offers and what the money guard reads" ) @pytest.mark.asyncio async def test_an_anonymous_researcher_still_gets_a_readable_greeting( db_session, ): """The whole reason personalising was safe: the token falls back to the literal word "submitter", so a researcher who gave only an address gets the wording these letters already had — never "Dear ,". """ from app.domains.bounty import catalog nameless = await svc.create_report( db_session, title="no name given", researcher_email="anon@example.com", actor=None, ) await db_session.flush() for key in catalog.TEMPLATE_KEYS: rendered, _ = await svc.preview_reply( db_session, nameless, template_key=key, amount_override=Decimal("100"), ) first = next( line for line in rendered.body.splitlines() if line.strip() ) assert "submitter" in first.lower(), (key, first) assert ", ," not in first and not first.strip().startswith(","), ( f"{key} produced {first!r}" )