diff --git a/app/api/routers/breeze_buddy/widget/handlers.py b/app/api/routers/breeze_buddy/widget/handlers.py index da53e9736..15ab3befa 100644 --- a/app/api/routers/breeze_buddy/widget/handlers.py +++ b/app/api/routers/breeze_buddy/widget/handlers.py @@ -412,11 +412,12 @@ async def create_widget_session_handler( body: CreateWidgetSessionRequest, request: Request ) -> CreateWidgetSessionResponse: """Create a widget session backed by a chat_session row.""" - cfg = await resolve_widget_config_for_request( + cfg, preview = await resolve_widget_config_for_request( request=request, public_widget_key=body.public_widget_key, rate_bucket="session_create", rate_limit=None, # use cfg.max_sessions_per_ip_hour default + preview_grant=body.preview_grant, ) template = await get_template_by_id_cached(cfg.template_id) @@ -473,6 +474,7 @@ async def create_widget_session_handler( widget_session_id=create_resp.session_id, widget_config_id=cfg.id, ttl_minutes=DEFAULT_WIDGET_TOKEN_TTL_MINUTES, + preview=preview, ) surface = _extract_widget_config(template) @@ -517,7 +519,8 @@ async def send_widget_message_handler( ): """Drive one chat turn. 409 if voice is currently active.""" cfg = await resolve_session_widget_config( - request=request, widget_config_id=ctx.widget_config_id + widget_config_id=ctx.widget_config_id, + preview=ctx.preview, ) await enforce_widget_ip_limit( @@ -582,7 +585,8 @@ async def send_widget_intent_handler( an intent is a turn, not a side channel. """ cfg = await resolve_session_widget_config( - request=request, widget_config_id=ctx.widget_config_id + widget_config_id=ctx.widget_config_id, + preview=ctx.preview, ) await enforce_widget_ip_limit( @@ -641,7 +645,8 @@ async def transcribe_widget_audio_handler( drops it into the composer for the user to edit and send via ``/message``. """ cfg = await resolve_session_widget_config( - request=request, widget_config_id=ctx.widget_config_id + widget_config_id=ctx.widget_config_id, + preview=ctx.preview, ) # Separate "transcribe" bucket (same per-merchant hourly cap as messages) @@ -715,7 +720,8 @@ async def approve_widget_tool_handler( from ``already_decided`` / ``lock_contended``. """ cfg = await resolve_session_widget_config( - request=request, widget_config_id=ctx.widget_config_id + widget_config_id=ctx.widget_config_id, + preview=ctx.preview, ) await enforce_widget_ip_limit( @@ -806,7 +812,8 @@ async def update_widget_context_handler( client-context keys, so the two never clobber each other. """ cfg = await resolve_session_widget_config( - request=request, widget_config_id=ctx.widget_config_id + widget_config_id=ctx.widget_config_id, + preview=ctx.preview, ) await enforce_widget_ip_limit( @@ -910,8 +917,8 @@ async def voice_connect_handler( + end_conversation.py for the resume + drain mechanics. """ cfg = await resolve_session_widget_config( - request=request, widget_config_id=ctx.widget_config_id, + preview=ctx.preview, preview_paused=False, ) @@ -1396,8 +1403,8 @@ async def try_on_widget_handler( history, ui_blocks, session state, or a log line. """ cfg = await resolve_session_widget_config( - request=request, widget_config_id=ctx.widget_config_id, + preview=ctx.preview, preview_paused=False, ) diff --git a/app/api/routers/breeze_buddy/widget_common.py b/app/api/routers/breeze_buddy/widget_common.py index f6424cfc1..d11316191 100644 --- a/app/api/routers/breeze_buddy/widget_common.py +++ b/app/api/routers/breeze_buddy/widget_common.py @@ -23,12 +23,12 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Tuple from urllib.parse import urlparse from fastapi import HTTPException, Request, Response, status -from app.core.config.static import WIDGET_CONSOLE_ORIGINS +from app.api.security.breeze_buddy.widget_token import is_preview_grant_for from app.core.logger import logger from app.database.accessor.breeze_buddy.widget_config import ( get_widget_config_by_id, @@ -139,27 +139,16 @@ async def _enforce_widget_ip_limit( ) -def is_console_origin(request: Request) -> bool: - """Is this our own console asking, rather than a merchant's storefront? - - Our console is the one place an operator looks at their own agent before - anyone else can, so it is exempt from the two rules written for anonymous - visitors: the site allow-list, and the active flag. See - ``WIDGET_CONSOLE_ORIGINS`` for what that exemption is and is not worth. - """ - origin = _caller_origin(request) - return bool(origin) and origin.rstrip("/") in WIDGET_CONSOLE_ORIGINS - - async def resolve_session_widget_config( - *, request: Request, widget_config_id: str, preview_paused: bool = True + *, widget_config_id: str, preview: bool, preview_paused: bool = True ) -> WidgetConfigResponse: """The widget a session token names; 401 if it is gone or switched off. - Our console may still chat with a switched-off widget (preview), unless - ``preview_paused=False`` (voice and try-on).""" + + A console preview session (``preview`` — the server-signed claim on its + widget token, never a request header) may still chat with a switched-off + widget, unless ``preview_paused=False`` (voice and try-on).""" cfg = await get_widget_config_by_id(widget_config_id) - preview = preview_paused and is_console_origin(request) - if cfg is None or (not cfg.active and not preview): + if cfg is None or (not cfg.active and not (preview and preview_paused)): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Widget configuration not found or inactive", @@ -168,23 +157,27 @@ async def resolve_session_widget_config( return cfg -def enforce_widget_origin(*, request: Request, cfg: WidgetConfigResponse) -> None: +def enforce_widget_origin( + *, request: Request, cfg: WidgetConfigResponse, preview: bool = False +) -> None: """403 unless the caller's Origin (or Referer-derived origin) is in the row's ``allowed_origins``. Empty list = deny-all. Shared by the key-resolved session path and the shop-resolved storefront-config path so the two doors can never drift on origin policy. - Our own console is always permitted: a merchant should not have to add us - to their production allow-list to look at their own agent. + A console preview (``preview``, proven by a preview grant minted behind + the console login) is exempt: a merchant should not have to add us to + their production allow-list to look at their own agent. The Origin header + alone never grants that — any non-browser client can set it (N-WID-3). """ + if preview: + return origin = _caller_origin(request) if origin is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Origin required for widget routes", ) - if origin.rstrip("/") in WIDGET_CONSOLE_ORIGINS: - return if not cfg.allowed_origins or origin not in cfg.allowed_origins: logger.warning( f"widget: origin {origin!r} not in allowed_origins for " @@ -202,7 +195,8 @@ async def resolve_widget_config_for_request( public_widget_key: str, rate_bucket: str, rate_limit: Optional[int] = None, -) -> WidgetConfigResponse: + preview_grant: Optional[str] = None, +) -> Tuple[WidgetConfigResponse, bool]: """Load the widget_config + enforce origin + per-IP rate limit. Args: @@ -215,22 +209,30 @@ async def resolve_widget_config_for_request( ``None``, the route is expected to pick a value off the loaded ``WidgetConfigResponse`` itself — used when the row hadn't been loaded yet. + preview_grant: A console preview grant (``mint_preview_grant``), + if the caller sent one. + + Returns: + The config, and whether this is a console preview (a live grant + for this widget). Behaviour: - Returns 404 (no echo) when the key is unknown or the row is - inactive — same reconnaissance-hardening as the demo router. + inactive and no preview grant covers it — same + reconnaissance-hardening as the demo router. - Returns 403 when the caller's origin (or Referer-derived origin) isn't in ``allowed_origins`` — same status when - ``allowed_origins`` is empty (deny-all by default). + ``allowed_origins`` is empty (deny-all by default). A preview + is exempt. - Returns 429 with ``Retry-After`` when the per-IP bucket for ``rate_bucket`` is over ``rate_limit``. """ cfg = await get_widget_config_by_public_key(public_widget_key) - # A paused widget still previews for us. Pausing is how a merchant stops - # serving shoppers, and the moment they most want to look at their agent - # is before they have ever unpaused it. - preview = cfg is not None and not cfg.active and is_console_origin(request) - if cfg is not None and preview: + # A paused widget still previews for the console. Pausing is how a + # merchant stops serving shoppers, and the moment they most want to look + # at their agent is before they have ever unpaused it. + preview = cfg is not None and is_preview_grant_for(preview_grant, cfg.id) + if cfg is not None and preview and not cfg.active: logger.info( "widget: console preview session for an inactive widget_config " f"(id={cfg.id})" @@ -248,7 +250,7 @@ async def resolve_widget_config_for_request( detail="Widget configuration not found", ) - enforce_widget_origin(request=request, cfg=cfg) + enforce_widget_origin(request=request, cfg=cfg, preview=preview) effective_limit = ( rate_limit if rate_limit is not None else cfg.max_sessions_per_ip_hour @@ -259,7 +261,7 @@ async def resolve_widget_config_for_request( limit=effective_limit, widget_config_id=cfg.id, ) - return cfg + return cfg, preview async def enforce_widget_ip_limit( diff --git a/app/api/routers/breeze_buddy/widget_config/__init__.py b/app/api/routers/breeze_buddy/widget_config/__init__.py index fc411a264..bec71dfdc 100644 --- a/app/api/routers/breeze_buddy/widget_config/__init__.py +++ b/app/api/routers/breeze_buddy/widget_config/__init__.py @@ -7,6 +7,7 @@ - ``GET /widget-config/{id}`` — get by id - ``PUT /widget-config/{id}`` — partial update - ``DELETE /widget-config/{id}`` — delete (admin only) +- ``POST /widget-config/{id}/preview`` — console preview grant Mirrors the templates router (see ``templates/__init__.py``) — thin routes that validate auth + RBAC, then delegate to handlers. @@ -27,6 +28,7 @@ WidgetConfigListResponse, WidgetConfigResponse, WidgetConfigUpdate, + WidgetPreviewGrantResponse, ) from .handlers import ( @@ -34,6 +36,7 @@ delete_widget_config_handler, get_widget_config_by_id_handler, list_widget_configs_handler, + mint_preview_grant_handler, update_widget_config_handler, ) @@ -95,6 +98,20 @@ async def get_widget_config( return await get_widget_config_by_id_handler(widget_config_id, current_user) +@router.post( + "/widget-config/{widget_config_id}/preview", + response_model=WidgetPreviewGrantResponse, +) +async def mint_preview_grant( + widget_config_id: str, + current_user: UserInfo = Depends(get_current_user_with_rbac), +) -> WidgetPreviewGrantResponse: + """Short-lived grant the console hands its preview frame, so the widget + can open a session on a paused widget. RBAC: same access as reading the + widget_config (403 otherwise).""" + return await mint_preview_grant_handler(widget_config_id, current_user) + + @router.put("/widget-config/{widget_config_id}", response_model=WidgetConfigResponse) async def update_widget_config( widget_config_id: str, diff --git a/app/api/routers/breeze_buddy/widget_config/handlers.py b/app/api/routers/breeze_buddy/widget_config/handlers.py index ce9faf48a..2660592a9 100644 --- a/app/api/routers/breeze_buddy/widget_config/handlers.py +++ b/app/api/routers/breeze_buddy/widget_config/handlers.py @@ -17,6 +17,10 @@ from fastapi import HTTPException, status from app.api.routers.breeze_buddy.templates.rbac import validate_template_access +from app.api.security.breeze_buddy.widget_token import ( + PREVIEW_GRANT_TTL_MINUTES, + mint_preview_grant, +) from app.core.logger import logger from app.database.accessor import get_template_by_id from app.database.accessor.breeze_buddy.widget_config import ( @@ -34,6 +38,7 @@ WidgetConfigListResponse, WidgetConfigResponse, WidgetConfigUpdate, + WidgetPreviewGrantResponse, ) # 32 url-safe bytes → ~43 chars; well inside the column's varchar(128) @@ -197,6 +202,17 @@ async def get_widget_config_by_id_handler( return cfg +async def mint_preview_grant_handler( + widget_config_id: str, current_user: UserInfo +) -> WidgetPreviewGrantResponse: + """A console preview grant for a widget the caller may read (N-WID-3).""" + cfg = await get_widget_config_by_id_handler(widget_config_id, current_user) + return WidgetPreviewGrantResponse( + preview_grant=mint_preview_grant(widget_config_id=cfg.id), + ttl_seconds=PREVIEW_GRANT_TTL_MINUTES * 60, + ) + + async def update_widget_config_handler( widget_config_id: str, body: WidgetConfigUpdate, diff --git a/app/api/security/breeze_buddy/widget_token.py b/app/api/security/breeze_buddy/widget_token.py index 5160a7985..b47443ed9 100644 --- a/app/api/security/breeze_buddy/widget_token.py +++ b/app/api/security/breeze_buddy/widget_token.py @@ -30,7 +30,7 @@ import uuid from datetime import timedelta -from typing import Annotated, Any, Dict +from typing import Annotated, Any, Dict, Optional import jwt as pyjwt from fastapi import Depends, HTTPException, Path, status @@ -45,20 +45,31 @@ _widget_bearer = HTTPBearer(auto_error=True, scheme_name="WidgetBearer") TYP_WIDGET = "widget" +TYP_WIDGET_PREVIEW = "widget_preview" # Default lifetime — see module docstring. DEFAULT_WIDGET_TOKEN_TTL_MINUTES = 24 * 60 +# A preview grant only has to outlive the console building its preview frame. +PREVIEW_GRANT_TTL_MINUTES = 15 class WidgetSessionContext: """Resolved widget-token claims, returned by ``require_widget_session``.""" - __slots__ = ("session_id", "widget_config_id", "subject") + __slots__ = ("session_id", "widget_config_id", "subject", "preview") - def __init__(self, *, session_id: str, widget_config_id: str, subject: str) -> None: + def __init__( + self, + *, + session_id: str, + widget_config_id: str, + subject: str, + preview: bool = False, + ) -> None: self.session_id = session_id self.widget_config_id = widget_config_id self.subject = subject + self.preview = preview def mint_widget_token( @@ -66,6 +77,7 @@ def mint_widget_token( widget_session_id: str, widget_config_id: str, ttl_minutes: int = DEFAULT_WIDGET_TOKEN_TTL_MINUTES, + preview: bool = False, ) -> str: """Issue a widget JWT bound to ``widget_session_id``. @@ -83,11 +95,47 @@ def mint_widget_token( "widget_session_id": widget_session_id, "widget_config_id": widget_config_id, } + if preview: + payload["preview"] = True return jwt_manager.create_access_token( payload, expires_delta=timedelta(minutes=ttl_minutes) ) +def mint_preview_grant(*, widget_config_id: str) -> str: + """Issue a console preview grant for one widget_config (N-WID-3). + + Minted only behind the console login (RBAC, widget access checked), it + lets a session be created on a PAUSED widget and from the console's own + origin. It is not a session token: ``require_widget_session`` rejects it + (``typ``), and the RBAC verifier rejects it (no ``username``). + """ + payload: Dict[str, Any] = { + "sub": f"widget-preview-{uuid.uuid4().hex[:12]}", + "typ": TYP_WIDGET_PREVIEW, + "widget_config_id": widget_config_id, + } + return jwt_manager.create_access_token( + payload, expires_delta=timedelta(minutes=PREVIEW_GRANT_TTL_MINUTES) + ) + + +def is_preview_grant_for(grant: Optional[str], widget_config_id: str) -> bool: + """True when ``grant`` is a live preview grant for this widget_config.""" + if not grant: + return False + try: + payload = pyjwt.decode( + grant, jwt_manager.secret_key, algorithms=[jwt_manager.algorithm] + ) + except pyjwt.InvalidTokenError: + return False + return ( + payload.get("typ") == TYP_WIDGET_PREVIEW + and payload.get("widget_config_id") == widget_config_id + ) + + def _decode_or_401(token: str) -> Dict[str, Any]: """Verify the JWT signature + standard claims, or raise 401.""" try: @@ -153,6 +201,7 @@ def require_widget_session( session_id=session_id, widget_config_id=widget_config_id, subject=str(payload.get("sub", "")), + preview=payload.get("preview") is True, ) diff --git a/app/core/config/static.py b/app/core/config/static.py index fbb1ae2e0..120971c22 100644 --- a/app/core/config/static.py +++ b/app/core/config/static.py @@ -1074,19 +1074,6 @@ def _positive_float(env_var: str, default: float) -> float: os.environ.get("MCP_PUBLIC_ENDPOINT_ENABLED", "false").lower() == "true" ) -# Our console's origins: they may preview a paused widget and skip the -# store's allowed_origins, so a merchant can try an agent before going live. -# Origin can be faked, so voice and try-on still need the widget active. -# Local consoles (http://localhost:5173) are added via .env, never by default. -WIDGET_CONSOLE_ORIGINS = [ - origin.strip().rstrip("/") - for origin in os.environ.get( - "WIDGET_CONSOLE_ORIGINS", - "https://breezebuddy.ai,https://buddy.breezelabs.app", - ).split(",") - if origin.strip() -] - # Template `custom` global functions execute author-supplied python_code. An # in-process interpreter sandbox is NOT a security boundary, so this feature is # OFF by default and must be explicitly enabled per-deployment (and only when diff --git a/app/schemas/breeze_buddy/chat.py b/app/schemas/breeze_buddy/chat.py index 5f1362951..9fa2fe863 100644 --- a/app/schemas/breeze_buddy/chat.py +++ b/app/schemas/breeze_buddy/chat.py @@ -703,6 +703,14 @@ class CreateWidgetSessionRequest(BaseModel): "'widget' block." ), ) + preview_grant: Optional[str] = Field( + None, + description=( + "Console preview grant from POST /widget-config/{id}/preview. " + "Lets the console open a session on a paused widget and skip the " + "site allow-list. Storefronts never send it." + ), + ) class CreateWidgetSessionResponse(BaseModel): diff --git a/app/schemas/breeze_buddy/widget_config.py b/app/schemas/breeze_buddy/widget_config.py index d6677dc1b..ff3382bb9 100644 --- a/app/schemas/breeze_buddy/widget_config.py +++ b/app/schemas/breeze_buddy/widget_config.py @@ -187,6 +187,13 @@ class DeleteWidgetConfigResponse(BaseModel): deleted_id: str +class WidgetPreviewGrantResponse(BaseModel): + """``POST /widget-config/{id}/preview`` — see ``mint_preview_grant``.""" + + preview_grant: str + ttl_seconds: int + + __all__ = [ "StorefrontWidgetConfigResponse", "WidgetAppearance", @@ -195,4 +202,5 @@ class DeleteWidgetConfigResponse(BaseModel): "WidgetConfigResponse", "WidgetConfigListResponse", "DeleteWidgetConfigResponse", + "WidgetPreviewGrantResponse", ] diff --git a/tests/test_widget_preview_grant.py b/tests/test_widget_preview_grant.py new file mode 100644 index 000000000..d3b8455a9 --- /dev/null +++ b/tests/test_widget_preview_grant.py @@ -0,0 +1,213 @@ +"""N-WID-3 — a paused widget previews only behind the console login. + +Before: an ``Origin: https://breezebuddy.ai`` header let any client open a +session on a PAUSED widget and skip the store's allow-list. Any non-browser +client can set that header. Now the bypass needs a preview grant, minted +by ``POST /widget-config/{id}/preview`` behind RBAC, and the session's +widget token carries a server-signed ``preview`` claim instead. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Optional + +import pytest +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials +from starlette.requests import Request + +from app.api.routers.breeze_buddy import widget_common +from app.api.routers.breeze_buddy.widget_config import handlers as config_handlers +from app.api.security.breeze_buddy.rbac_token import rbac_token_manager +from app.api.security.breeze_buddy.widget_token import ( + TYP_WIDGET_PREVIEW, + is_preview_grant_for, + mint_preview_grant, + mint_widget_token, + require_widget_session, +) +from app.core.security.jwt import jwt_manager +from app.schemas import UserInfo, UserRole +from app.schemas.breeze_buddy.widget_config import WidgetConfigResponse + +WIDGET = "00000000-0000-0000-0000-0000000000aa" +OTHER_WIDGET = "00000000-0000-0000-0000-0000000000bb" +CONSOLE = "https://breezebuddy.ai" +STORE = "https://store.example" + + +def _cfg(*, active: bool) -> WidgetConfigResponse: + return WidgetConfigResponse( + id=WIDGET, + reseller_id="buddy-assist", + merchant_id="store.myshopify.com", + public_widget_key="public-key-123", + template_id="00000000-0000-0000-0000-000000000002", + allowed_origins=[STORE], + active=active, + ) + + +def _request(origin: str) -> Request: + return Request( + { + "type": "http", + "method": "POST", + "path": "/agent/voice/breeze-buddy/widget/session", + "headers": [(b"origin", origin.encode())], + "client": ("203.0.113.9", 51234), + "query_string": b"", + } + ) + + +@pytest.fixture() +def widget(monkeypatch): + def install(cfg: WidgetConfigResponse) -> None: + async def by_key(_key: str): + return cfg + + async def no_limit(**_kwargs) -> None: + return None + + monkeypatch.setattr(widget_common, "get_widget_config_by_public_key", by_key) + monkeypatch.setattr(widget_common, "_enforce_widget_ip_limit", no_limit) + + return install + + +async def _create(origin: str, grant: Optional[str] = None): + return await widget_common.resolve_widget_config_for_request( + request=_request(origin), + public_widget_key="public-key-123", + rate_bucket="session_create", + preview_grant=grant, + ) + + +# -- the bug --------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_console_origin_alone_no_longer_opens_a_paused_widget(widget): + widget(_cfg(active=False)) + with pytest.raises(HTTPException) as raised: + await _create(CONSOLE) + assert raised.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_console_origin_alone_no_longer_skips_the_allow_list(widget): + widget(_cfg(active=True)) + with pytest.raises(HTTPException) as raised: + await _create(CONSOLE) + assert raised.value.status_code == 403 + + +# -- the console path ------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_a_grant_previews_a_paused_widget_from_the_console(widget): + widget(_cfg(active=False)) + cfg, preview = await _create(CONSOLE, mint_preview_grant(widget_config_id=WIDGET)) + assert (cfg.id, preview) == (WIDGET, True) + + +@pytest.mark.asyncio +async def test_a_grant_for_another_widget_does_not(widget): + widget(_cfg(active=False)) + with pytest.raises(HTTPException) as raised: + await _create(CONSOLE, mint_preview_grant(widget_config_id=OTHER_WIDGET)) + assert raised.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_storefronts_are_unchanged(widget): + widget(_cfg(active=True)) + cfg, preview = await _create(STORE) + assert (cfg.id, preview) == (WIDGET, False) + + +# -- the grant itself ------------------------------------------------------ + + +def test_grant_is_bound_to_one_widget_and_its_type(): + grant = mint_preview_grant(widget_config_id=WIDGET) + assert is_preview_grant_for(grant, WIDGET) + assert not is_preview_grant_for(grant, OTHER_WIDGET) + assert not is_preview_grant_for(None, WIDGET) + assert not is_preview_grant_for("not-a-jwt", WIDGET) + session_token = mint_widget_token(widget_session_id="s1", widget_config_id=WIDGET) + assert not is_preview_grant_for(session_token, WIDGET) + + +def test_expired_grant_is_refused(): + expired = jwt_manager.create_access_token( + {"sub": "x", "typ": TYP_WIDGET_PREVIEW, "widget_config_id": WIDGET}, + expires_delta=timedelta(seconds=-1), + ) + assert not is_preview_grant_for(expired, WIDGET) + + +def test_grant_is_neither_a_session_token_nor_a_console_login(): + grant = mint_preview_grant(widget_config_id=WIDGET) + creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials=grant) + with pytest.raises(HTTPException): + require_widget_session(session_id="s1", creds=creds) + with pytest.raises(HTTPException): + rbac_token_manager.verify_rbac_token(grant) + + +def test_preview_claim_rides_the_session_token(): + def ctx(token: str): + creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + return require_widget_session(session_id="s1", creds=creds) + + plain = mint_widget_token(widget_session_id="s1", widget_config_id=WIDGET) + preview = mint_widget_token( + widget_session_id="s1", widget_config_id=WIDGET, preview=True + ) + assert ctx(plain).preview is False + assert ctx(preview).preview is True + + +# -- the RBAC endpoint ----------------------------------------------------- + + +def _user(role: UserRole, merchant_ids: list[str]) -> UserInfo: + return UserInfo( + id="u1", + username="u1", + role=role, + reseller_ids=["buddy-assist"], + merchant_ids=merchant_ids, + permissions=[], + ) + + +@pytest.mark.asyncio +async def test_endpoint_mints_for_the_owning_merchant(monkeypatch): + async def by_id(_id: str): + return _cfg(active=False) + + monkeypatch.setattr(config_handlers, "get_widget_config_by_id", by_id) + out = await config_handlers.mint_preview_grant_handler( + WIDGET, _user(UserRole.MERCHANT, ["store.myshopify.com"]) + ) + assert is_preview_grant_for(out.preview_grant, WIDGET) + assert out.ttl_seconds == 15 * 60 + + +@pytest.mark.asyncio +async def test_endpoint_refuses_another_merchant(monkeypatch): + async def by_id(_id: str): + return _cfg(active=False) + + monkeypatch.setattr(config_handlers, "get_widget_config_by_id", by_id) + with pytest.raises(HTTPException) as raised: + await config_handlers.mint_preview_grant_handler( + WIDGET, _user(UserRole.MERCHANT, ["other.myshopify.com"]) + ) + assert raised.value.status_code == 403 diff --git a/tests/test_widget_preview_paused_agent.py b/tests/test_widget_preview_paused_agent.py index 42eab5de3..bce5ade79 100644 --- a/tests/test_widget_preview_paused_agent.py +++ b/tests/test_widget_preview_paused_agent.py @@ -7,9 +7,10 @@ a session, sent one word, and got 401 — rendered to the merchant as "Your chat session expired", on a session one second old. -The rule now lives in one place. These tests hold both halves of it: the -console gets through a paused widget, and a storefront — even one on the -allow-list — does not. +The rule now lives in one place. These tests hold both halves of it: a +console preview session gets through a paused widget, and a storefront +session does not. "Preview" is the server-signed claim on the widget token +(minted from a console preview grant), never the Origin header (N-WID-3). """ from __future__ import annotations @@ -18,12 +19,9 @@ import pytest from fastapi import HTTPException -from starlette.requests import Request from app.api.routers.breeze_buddy import widget_common -CONSOLE = "https://breezebuddy.ai" -STOREFRONT = "https://beyond-bound.myshopify.com" WIDGET_CONFIG_ID = "00000000-0000-0000-0000-0000000000aa" @@ -35,21 +33,6 @@ def __init__(self, active: bool) -> None: self.active = active -def _request(origin: str) -> Request: - return Request( - { - "type": "http", - "method": "POST", - "path": "/agent/voice/breeze-buddy/widget/session/s1/message", - "headers": [(b"origin", origin.encode())], - "client": ("203.0.113.9", 51234), - "query_string": b"", - "scheme": "http", - "server": ("localhost", 8000), - } - ) - - @pytest.fixture() def row(monkeypatch): """Install the widget_config the gate will read back.""" @@ -63,19 +46,33 @@ async def fake(_widget_config_id: str): return install -async def _resolve(origin: str, *, preview_paused: bool = True): +async def _resolve(*, preview: bool, preview_paused: bool = True): return await widget_common.resolve_session_widget_config( - request=_request(origin), widget_config_id=WIDGET_CONFIG_ID, + preview=preview, preview_paused=preview_paused, ) @pytest.mark.asyncio -async def test_only_the_console_can_talk_to_a_paused_agent(row) -> None: +async def test_only_a_preview_session_can_talk_to_a_paused_agent(row) -> None: + row(_Config(active=False)) + assert (await _resolve(preview=True)).id == WIDGET_CONFIG_ID + # A storefront session cannot: a paused widget serves no shopper. + with pytest.raises(HTTPException) as raised: + await _resolve(preview=False) + assert raised.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_voice_and_try_on_stay_paused_even_for_a_preview(row) -> None: row(_Config(active=False)) - assert (await _resolve(CONSOLE)).id == WIDGET_CONFIG_ID - # A storefront, even its own, cannot: a paused widget serves no shopper. with pytest.raises(HTTPException) as raised: - await _resolve(STOREFRONT) + await _resolve(preview=True, preview_paused=False) assert raised.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_an_active_widget_serves_every_session(row) -> None: + row(_Config(active=True)) + assert (await _resolve(preview=False)).id == WIDGET_CONFIG_ID