diff --git a/api/libs/helper.py b/api/libs/helper.py index b66324a5d7..7b9128ab7a 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -39,6 +39,7 @@ class _TokenData(TypedDict, total=False): token_type: str code: str old_email: str + phase: str _token_data_adapter: TypeAdapter[_TokenData] = TypeAdapter(_TokenData) diff --git a/api/tests/unit_tests/libs/test_token_manager.py b/api/tests/unit_tests/libs/test_token_manager.py new file mode 100644 index 0000000000..832210c7f2 --- /dev/null +++ b/api/tests/unit_tests/libs/test_token_manager.py @@ -0,0 +1,58 @@ +""" +Regression tests for the `_TokenData` TypedDict used by +`libs.helper.TokenManager`. + +These tests guard the contract that every field a caller writes via +`generate_token` survives the TypedDict-validated round-trip performed +by `get_token_data`. Specifically, the `phase` field that the console +and web `forgot-password` + `change-email` controllers depend on for +the security check introduced in PR #35425 (GHSA-4q3w-q5mc-45rq) must +be preserved — otherwise downstream `if data.get("phase", "") != "reset"` +checks always fail with `InvalidTokenError`. +""" + +import json + +# pyright: reportPrivateUsage=false +from libs.helper import _token_data_adapter + + +def test_token_data_adapter_preserves_phase_field() -> None: + """`phase` written by callers like generate_reset_password_token must + survive the TypedDict-validated round-trip in get_token_data. + + Regression: PR #34380 introduced `_TokenData` but did not list + `phase`, so the TypeAdapter silently dropped it and the security + gate from PR #35425 (GHSA-4q3w-q5mc-45rq) always failed. + """ + payload = { + "account_id": None, + "email": "user@example.com", + "token_type": "reset_password", + "code": "123456", + "phase": "reset", + } + data = dict(_token_data_adapter.validate_json(json.dumps(payload))) + + assert data.get("phase") == "reset", ( + "phase field was stripped by the _TokenData TypedDict adapter; " + "the forgot-password phase-bound check (PR #35425) will always fail." + ) + + +def test_token_data_adapter_preserves_change_email_payload() -> None: + """Sanity round-trip for the change-email flow: every field set by + `generate_change_email_token` must come back, including the phase + string the controller branches on.""" + payload = { + "account_id": "acc-1", + "email": "new@example.com", + "token_type": "change_email", + "code": "654321", + "old_email": "old@example.com", + "phase": "verify_old_email", + } + data = dict(_token_data_adapter.validate_json(json.dumps(payload))) + + assert data.get("old_email") == "old@example.com" + assert data.get("phase") == "verify_old_email"