Чем вход может закончиться#
Успешный вход проходит через второй фактор. Остальные ответы должны иметь собственные имена: тогда приложение ловит ожидаемый отказ по типу, а не разбирает текст или адрес страницы.
Случай |
Что получает вызывающий |
|---|---|
Неверный пароль |
|
Аккаунт не найден |
|
Аккаунт заблокирован |
|
Нужен второй фактор |
|
Неверный код |
|
Нужна капча |
|
→ Request · POST /login/password
{
"login_id": "login:ada@mail.example",
"password": "wrong"
}
← Response · 200 OK
{
"status": "wrong_password",
"body": {
"code": "wrong_password",
"message": "Password is incorrect"
}
}
В JSON имя варианта повторяется во внешнем status и во внутреннем body.code. Const
проверяет оба дискриминатора до создания исключения. Капча приходит с HTTP-статусом 403,
остальные ожидаемые ответы этого сайта остаются внутри 200.
# examples/mail/http/login_probe.py
@dataclass(frozen=True, slots=True)
class WrongPasswordDetails:
code: Annotated[str, Const("wrong_password")]
message: str
@dataclass(frozen=True, slots=True)
class AccountNotFoundDetails:
code: Annotated[str, Const("account_not_found")]
message: str
@dataclass(frozen=True, slots=True)
class AccountBlockedDetails:
code: Annotated[str, Const("account_blocked")]
message: str
@dataclass(frozen=True, slots=True)
class WrongCodeDetails:
code: Annotated[str, Const("wrong_code")]
message: str
@dataclass(frozen=True, slots=True)
class WrongPasswordResponse:
status: Annotated[str, Const("wrong_password")]
body: Payload[WrongPasswordDetails]
@dataclass(frozen=True, slots=True)
class AccountNotFoundResponse:
status: Annotated[str, Const("account_not_found")]
body: Payload[AccountNotFoundDetails]
@dataclass(frozen=True, slots=True)
class AccountBlockedResponse:
status: Annotated[str, Const("account_blocked")]
body: Payload[AccountBlockedDetails]
@dataclass(frozen=True, slots=True)
class WrongCodeResponse:
status: Annotated[str, Const("wrong_code")]
body: Payload[WrongCodeDetails]
@dataclass(frozen=True, slots=True)
class CaptchaRequiredResponse:
kind: Annotated[str, Const("captcha")]
site_key: str
class WrongPassword(ApiError[WrongPasswordResponse]):
pass
class AccountNotFound(ApiError[AccountNotFoundResponse]):
pass
class AccountBlocked(ApiError[AccountBlockedResponse]):
pass
class WrongCode(ApiError[WrongCodeResponse]):
pass
class CaptchaRequired(ApiError[CaptchaRequiredResponse]):
pass
class LoginService:
"""Failures shared by all three operations of the login service."""
base_url = BASE_URL
errors = (
Error(200, Json(WrongPasswordResponse), exception=WrongPassword),
Error(200, Json(AccountNotFoundResponse), exception=AccountNotFound),
Error(200, Json(AccountBlockedResponse), exception=AccountBlocked),
Error(200, Json(WrongCodeResponse), exception=WrongCode),
Error(403, Json(CaptchaRequiredResponse), exception=CaptchaRequired),
)
Ответ сайта |
Правило |
Результат |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
успешный ответ |
|
|
|
|
HTTP |
|
|
Файл примера целиком
"""Probe a three-step login declaration and its six documented outcomes."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from eazy_sdk import Http, HttpOperation, JsonField, SyncApi, op
from eazy_sdk.response import ApiError, Const, Error, Json, Payload
BASE_URL = "https://mail.example"
WRONG_INPUT = "wrong"
VALID_INPUT = "correct"
@dataclass(frozen=True, slots=True)
class PasswordStep:
login_id: str
@dataclass(frozen=True, slots=True)
class OtpStep:
login_id: str
destination: str
@dataclass(frozen=True, slots=True)
class Session:
access_token: str
@dataclass(frozen=True, slots=True)
class PasswordStepResponse:
status: Annotated[str, Const("ok")]
body: Payload[PasswordStep]
@dataclass(frozen=True, slots=True)
class OtpStepResponse:
status: Annotated[str, Const("ok")]
body: Payload[OtpStep]
@dataclass(frozen=True, slots=True)
class SessionResponse:
status: Annotated[str, Const("ok")]
body: Payload[Session]
# region docs: http-login-failures
# examples/mail/http/login_probe.py
@dataclass(frozen=True, slots=True)
class WrongPasswordDetails:
code: Annotated[str, Const("wrong_password")]
message: str
@dataclass(frozen=True, slots=True)
class AccountNotFoundDetails:
code: Annotated[str, Const("account_not_found")]
message: str
@dataclass(frozen=True, slots=True)
class AccountBlockedDetails:
code: Annotated[str, Const("account_blocked")]
message: str
@dataclass(frozen=True, slots=True)
class WrongCodeDetails:
code: Annotated[str, Const("wrong_code")]
message: str
@dataclass(frozen=True, slots=True)
class WrongPasswordResponse:
status: Annotated[str, Const("wrong_password")]
body: Payload[WrongPasswordDetails]
@dataclass(frozen=True, slots=True)
class AccountNotFoundResponse:
status: Annotated[str, Const("account_not_found")]
body: Payload[AccountNotFoundDetails]
@dataclass(frozen=True, slots=True)
class AccountBlockedResponse:
status: Annotated[str, Const("account_blocked")]
body: Payload[AccountBlockedDetails]
@dataclass(frozen=True, slots=True)
class WrongCodeResponse:
status: Annotated[str, Const("wrong_code")]
body: Payload[WrongCodeDetails]
@dataclass(frozen=True, slots=True)
class CaptchaRequiredResponse:
kind: Annotated[str, Const("captcha")]
site_key: str
class WrongPassword(ApiError[WrongPasswordResponse]):
pass
class AccountNotFound(ApiError[AccountNotFoundResponse]):
pass
class AccountBlocked(ApiError[AccountBlockedResponse]):
pass
class WrongCode(ApiError[WrongCodeResponse]):
pass
class CaptchaRequired(ApiError[CaptchaRequiredResponse]):
pass
class LoginService:
"""Failures shared by all three operations of the login service."""
base_url = BASE_URL
errors = (
Error(200, Json(WrongPasswordResponse), exception=WrongPassword),
Error(200, Json(AccountNotFoundResponse), exception=AccountNotFound),
Error(200, Json(AccountBlockedResponse), exception=AccountBlocked),
Error(200, Json(WrongCodeResponse), exception=WrongCode),
Error(403, Json(CaptchaRequiredResponse), exception=CaptchaRequired),
)
# endregion docs: http-login-failures
# region docs: http-login-operations
# examples/mail/http/login_probe.py
@dataclass(frozen=True, slots=True, kw_only=True)
class Identify(HttpOperation[PasswordStep]):
__http__ = Http.post("/login/identify", success={200: Json(PasswordStepResponse)})
email: JsonField[str]
@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitPassword(HttpOperation[OtpStep]):
__http__ = Http.post("/login/password", success={200: Json(OtpStepResponse)})
login_id: JsonField[str]
password: JsonField[str]
@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitOtp(HttpOperation[Session]):
__http__ = Http.post("/login/otp", success={200: Json(SessionResponse)})
login_id: JsonField[str]
code: JsonField[str]
# endregion docs: http-login-operations
# region docs: http-login-router
# examples/mail/http/login_probe.py
class LoginApi(LoginService, SyncApi):
identify = op(Identify)
password = op(SubmitPassword)
otp = op(SubmitOtp)
# endregion docs: http-login-router
"""Run all six documented outcomes of the HTTP login."""
from __future__ import annotations
from collections.abc import Callable
from eazy_sdk.response import ApiError
from examples.mail.http._client import mail_client
from examples.mail.http.login_probe import VALID_INPUT, WRONG_INPUT, LoginApi
def _print_error(label: str, call: Callable[[], object]) -> None:
try:
call()
except ApiError as error:
print(f"{label}: {type(error).__name__}")
def main() -> None:
with mail_client() as client:
login = LoginApi(client)
password_step = login.identify(email="ada@mail.example")
_print_error(
"wrong password",
lambda: login.password(login_id=password_step.login_id, password=WRONG_INPUT),
)
_print_error("account not found", lambda: login.identify(email="missing@mail.example"))
_print_error("account blocked", lambda: login.identify(email="blocked@mail.example"))
otp_step = login.password(login_id=password_step.login_id, password=VALID_INPUT)
print(f"second factor: {type(otp_step).__name__}")
_print_error(
"wrong code",
lambda: login.otp(login_id=otp_step.login_id, code="000000"),
)
_print_error(
"captcha",
lambda: login.password(
login_id=login.identify(email="captcha@mail.example").login_id,
password=VALID_INPUT,
),
)
if __name__ == "__main__":
main()
Запуск: uv run python -m examples.mail.http.login_failures.
wrong password: WrongPassword
account not found: AccountNotFound
account blocked: AccountBlocked
second factor: OtpStep
wrong code: WrongCode
captcha: CaptchaRequired
Страница сообщает об отказах атрибутами data-code. Правила аккаунта принадлежат роутеру,
а неверный пароль, капча и неверный код остаются у операций своих шагов.
При неверном пароле браузер видит этот фрагмент фактического ответа сайта:
<main data-page="failure" data-code="wrong_password">
<p role="alert">Password is incorrect</p>
</main>
# examples/mail/browser/login_probe.py
ACCOUNT_FAILURES = (
Failure(
when=visible(css('[data-code="account_not_found"]')),
exception=AccountNotFoundError,
),
Failure(
when=visible(css('[data-code="account_blocked"]')),
exception=AccountBlockedError,
),
)
PASSWORD_FAILURES = (
Failure(
when=visible(css('[data-code="wrong_password"]')),
exception=WrongPasswordError,
),
Failure(
when=visible(css('[data-page="captcha"]')),
exception=CaptchaRequiredError,
),
)
OTP_FAILURES = (
Failure(
when=visible(css('[data-code="wrong_code"]')),
exception=WrongCodeError,
),
)
Признак страницы |
Где объявлен |
Результат |
|---|---|---|
|
шаг пароля |
|
|
|
|
|
|
|
поле |
исход шага пароля |
|
|
шаг кода |
|
|
шаг пароля |
|
Файл примера целиком
"""Type probe for a three-step browser login expressed with ``to=`` states."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated, NoReturn
from eazy_sdk_browser import (
AsyncBrowserApi,
Browser,
BrowserOperation,
Element,
Failure,
PageError,
css,
outcomes,
visible,
when,
)
from eazy_sdk import op
VALID_LOGIN_INPUT = "correct"
class WrongPasswordError(PageError):
"""Пароль не подошёл."""
class AccountNotFoundError(PageError):
"""Аккаунт не найден."""
class AccountBlockedError(PageError):
"""Аккаунт заблокирован."""
class WrongCodeError(PageError):
"""Код подтверждения не подошёл."""
class CaptchaRequiredError(PageError):
"""Страница запросила капчу."""
# region docs: browser-login-failures
# examples/mail/browser/login_probe.py
ACCOUNT_FAILURES = (
Failure(
when=visible(css('[data-code="account_not_found"]')),
exception=AccountNotFoundError,
),
Failure(
when=visible(css('[data-code="account_blocked"]')),
exception=AccountBlockedError,
),
)
PASSWORD_FAILURES = (
Failure(
when=visible(css('[data-code="wrong_password"]')),
exception=WrongPasswordError,
),
Failure(
when=visible(css('[data-page="captcha"]')),
exception=CaptchaRequiredError,
),
)
OTP_FAILURES = (
Failure(
when=visible(css('[data-code="wrong_code"]')),
exception=WrongCodeError,
),
)
# endregion docs: browser-login-failures
class IdentifyPage:
email: Annotated[Element, css('input[name="email"]')]
submit: Annotated[Element, css('button[type="submit"]')]
class PasswordPage:
password: Annotated[Element, css('input[name="password"]')]
submit: Annotated[Element, css('button[type="submit"]')]
class OtpPage:
code: Annotated[Element, css('input[name="code"]')]
submit: Annotated[Element, css('button[type="submit"]')]
@dataclass(frozen=True, slots=True)
class Mailbox:
api: LoginPortal
@dataclass(frozen=True, slots=True)
class PasswordStep:
api: LoginPortal
async def submit(self, password: str) -> OtpStep:
return await self.api.password(password=password)
@dataclass(frozen=True, slots=True)
class OtpStep:
api: LoginPortal
async def submit(self, code: str) -> Mailbox:
return await self.api.otp(code=code)
async def _identify_stalled(content: IdentifyPage) -> NoReturn:
_ = content
raise TimeoutError("password step did not appear")
async def _password_stalled(content: PasswordPage) -> NoReturn:
_ = content
raise TimeoutError("OTP step did not appear")
async def _otp_stalled(content: OtpPage) -> NoReturn:
_ = content
raise TimeoutError("mailbox did not appear")
# region docs: browser-login-operations
# examples/mail/browser/login_probe.py
@dataclass(frozen=True, slots=True, kw_only=True)
class Identify(BrowserOperation[IdentifyPage, PasswordStep]):
__browser__ = Browser.act(
IdentifyPage,
at=css('input[name="email"]'),
outcomes=outcomes(
when(visible(css('input[name="password"]')), to=PasswordStep),
otherwise=_identify_stalled,
),
)
email: str
async def act(self, content: IdentifyPage) -> None:
await content.email.fill(self.email)
await content.submit.click()
@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitPassword(BrowserOperation[PasswordPage, OtpStep]):
__browser__ = Browser.act(
PasswordPage,
at=css('input[name="password"]'),
errors=PASSWORD_FAILURES,
outcomes=outcomes(
when(visible(css('input[name="code"]')), to=OtpStep),
otherwise=_password_stalled,
),
)
password: str
async def act(self, content: PasswordPage) -> None:
await content.password.fill(self.password)
await content.submit.click()
@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitOtp(BrowserOperation[OtpPage, Mailbox]):
__browser__ = Browser.act(
OtpPage,
at=css('input[name="code"]'),
errors=OTP_FAILURES,
outcomes=outcomes(
when(visible(css('[data-page="mailbox"]')), to=Mailbox),
otherwise=_otp_stalled,
),
)
code: str
async def act(self, content: OtpPage) -> None:
await content.code.fill(self.code)
await content.submit.click()
# endregion docs: browser-login-operations
# region docs: browser-login-router
# examples/mail/browser/login_probe.py
class LoginPortal(AsyncBrowserApi):
errors = ACCOUNT_FAILURES
identify = op(Identify)
password = op(SubmitPassword)
otp = op(SubmitOtp)
# endregion docs: browser-login-router
# region docs: browser-login-flow
# examples/mail/browser/login_probe.py
async def typed_login(portal: LoginPortal) -> Mailbox:
"""Make loss of a step type fail the ordinary mypy gate."""
password: PasswordStep = await portal.identify(email="ada@mail.example")
otp: OtpStep = await password.submit(password=VALID_LOGIN_INPUT)
mailbox: Mailbox = await otp.submit(code="123456")
return mailbox
# endregion docs: browser-login-flow
"""Execute all six browser-login outcomes against the teaching mail site."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable
from eazy_sdk_browser import BrowserCallOptions, PageError
from examples.mail.browser.login_probe import (
VALID_LOGIN_INPUT,
LoginPortal,
OtpStep,
PasswordStep,
)
from examples.mail.site._playwright import MailBrowser, playwright_mail
OPTIONS = BrowserCallOptions(timeout=5.0)
REJECTED_INPUT = "wrong"
async def _portal(runtime: MailBrowser) -> LoginPortal:
await runtime.reset()
return LoginPortal(runtime.client)
async def _password(portal: LoginPortal, email: str = "ada@mail.example") -> PasswordStep:
return await portal.identify(email=email, options=OPTIONS)
async def _otp(portal: LoginPortal, email: str = "ada@mail.example") -> OtpStep:
password = await _password(portal, email)
return await password.api.password(password=VALID_LOGIN_INPUT, options=OPTIONS)
async def _report(label: str, result: Awaitable[object]) -> None:
try:
value = await result
except PageError as error:
print(f"{label}: {type(error).__name__}")
else:
print(f"{label}: {type(value).__name__}")
async def run() -> None:
async with playwright_mail() as runtime:
portal = await _portal(runtime)
password = await _password(portal)
await _report(
"wrong password",
password.api.password(password=REJECTED_INPUT, options=OPTIONS),
)
await _report(
"account not found",
_password(await _portal(runtime), "missing@mail.example"),
)
await _report(
"account blocked",
_password(await _portal(runtime), "blocked@mail.example"),
)
await _report("second factor", _otp(await _portal(runtime)))
otp = await _otp(await _portal(runtime))
await _report("wrong code", otp.api.otp(code="000000", options=OPTIONS))
captcha_password = await _password(
await _portal(runtime),
"captcha@mail.example",
)
await _report(
"captcha",
captcha_password.api.password(password=VALID_LOGIN_INPUT, options=OPTIONS),
)
def main() -> None:
asyncio.run(run())
if __name__ == "__main__":
main()
Команда uv run python -m examples.mail.browser.login_failures запускает Chromium через
PlaywrightDriver. Перехват запросов передаёт формы тому же MailSite, а операция читает
DOM-признаки полученной страницы.
wrong password: WrongPasswordError
account not found: AccountNotFoundError
account blocked: AccountBlockedError
second factor: OtpStep
wrong code: WrongCodeError
captcha: CaptchaRequiredError
Примечание
OtpStep не является ошибкой. Это ожидаемое продолжение входа, поэтому оно объявлено исходом
операции, а не исключением.