"""Shared Playwright auth helpers (phase 16 + phase 79). ``login`` drives the REAL form login on /login.html (fill → submit → redirect) so every story that needs the admin does exactly what a human would — no cookie surgery. ``password=None`` uses the shared E2E admin password (success path); pass a wrong value to drive the error state (no redirect, ``#login-error`` role=alert visible, still anonymous). ``login_with_token`` (phase 79) drives the REAL in-app token gate on the shell the same way (fill → submit): pass an admin-issued token for the success contract (the gate hides, the app is interactive); call it with the default all-zeros sentinel — or ``expect_error=True`` for a token that USED to be valid (revocation) — for the wrong-token contract (``#auth-gate-error`` role=alert visible, the gate stays, still anonymous). Task 07's story suite is the first consumer. """ from __future__ import annotations from playwright.sync_api import Page, expect from e2e.conftest import ADMIN_PASSWORD # noqa: F401 (re-exported for tests) DEFAULT_NEXT = "/sources.html" #: The wrong-token sentinel (phase 79): the ``bor_`` prefix plus 32 zero #: hex chars. The admin issues ``bor_`` + ``secrets.token_hex(16)``, so #: an all-zeros token is never a live one — the DEFAULT call drives the #: wrong-token contract without needing a real token at all. WRONG_TOKEN = "bor_" + "0" * 32 def login_with_token( page: Page, app_url: str, token: str = WRONG_TOKEN, next: str = "/", expect_error: bool | None = None, ) -> None: """Drive the real in-app token gate (phase 79) on the shell. ``page.goto`` the shell — the test contexts are fresh, so no ``bor.token`` is cached in localStorage and ``#auth-gate`` is the visible surface — then fill ``#auth-gate-input`` with ``token`` and submit the gate's form: * success (an explicitly-passed valid, unrevoked token — with ``expect_error`` left ``None`` the branch is derived from the all-zeros ``WRONG_TOKEN`` sentinel) → ``#auth-gate`` hides and the app is interactive (the composer is reachable) at ``next``; * wrong / revoked (``expect_error=True``, or the sentinel default) → ``#auth-gate-error`` (role=alert) is visible, the gate stays visible — the visible gate IS the anonymity proof, the UI mirror of ``login``'s wrong-password branch — and the visitor is still anonymous. """ page.goto(app_url + next) gate = page.locator("#auth-gate") expect(gate).to_be_visible(timeout=30_000) page.fill("#auth-gate-input", token) page.click("#auth-gate-form button") error_expected = token == WRONG_TOKEN if expect_error is None else expect_error if error_expected: error = page.locator("#auth-gate-error") expect(error).to_have_attribute("role", "alert") expect(error).to_be_visible(timeout=15_000) expect(gate).to_be_visible() # the gate is the anonymity proof return expect(gate).to_be_hidden(timeout=30_000) expect(page.locator("#message-input")).to_be_visible() # app is interactive expect(page).to_have_url(app_url + next) def login(page: Page, app_url: str, password: str | None = None, next: str | None = None) -> None: """Perform the real form login and wait for its outcome. * correct password (or ``password=None`` → the shared admin password) → redirects to ``next`` (default ``/sources.html``); * wrong password → ``#login-error`` (role=alert) is visible, the URL never changes, and the visitor is still anonymous. Idempotent (phase 79): in a context that is ALREADY signed in the login page boots straight to ``next`` (login.js's whoami redirect — no form) and the call returns as a success; a test may sign in once per context and call it again from a later section without breaking. """ attempt = ADMIN_PASSWORD if password is None else password url = f"{app_url}/login.html" if next is not None: url += f"?next={next}" page.goto(url) if attempt == ADMIN_PASSWORD: # Phase 79 (task 04): the helper must be IDEMPOTENT — a context # that is ALREADY signed in never sees the form (login.js's # boot whoami redirect goes straight to `next`), and a test that # signs in twice (two sections, one context) must not break on # the second call. try: expect(page.locator("#login-password")).to_be_visible(timeout=2_000) except AssertionError: expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000) return else: expect(page.locator("#login-password")).to_be_visible() page.fill("#login-password", attempt) page.click("#login-form button[type=submit]") if attempt != ADMIN_PASSWORD: expect(page.locator("#login-error")).to_be_visible(timeout=15_000) expect(page).to_have_url(url) # no redirect on failure return expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)