diff --git a/py/BUILD.bazel b/py/BUILD.bazel index 465bdaa018790..2ddb9738ad142 100644 --- a/py/BUILD.bazel +++ b/py/BUILD.bazel @@ -739,6 +739,7 @@ generate_bidi( extra_srcs = [ "//py/private:_event_manager.py", "//py/private:_network_handlers.py", + "//py/private:_permissions_handlers.py", "//py/private:_script_handlers.py", "//py/private:cdp.py", ], diff --git a/py/generate_bidi.py b/py/generate_bidi.py index 9f88c720dd2b5..a0795576de64b 100755 --- a/py/generate_bidi.py +++ b/py/generate_bidi.py @@ -854,7 +854,7 @@ def generate_code(self, enhancements: dict[str, Any] | None = None) -> str: if self.events: code += " EVENT_CONFIGS: dict[str, EventConfig] = {}\n" # Will be populated after types are defined - if self.name == "script": + if self.name in ("script", "permissions"): code += " def __init__(self, conn, driver=None) -> None:\n" code += " self._conn = conn\n" code += " self._driver = driver\n" diff --git a/py/private/BUILD.bazel b/py/private/BUILD.bazel index a03cbf0fc3814..e9ef81cd0298b 100644 --- a/py/private/BUILD.bazel +++ b/py/private/BUILD.bazel @@ -3,6 +3,7 @@ load("@rules_python//python:defs.bzl", "py_binary") exports_files([ "_event_manager.py", "_network_handlers.py", + "_permissions_handlers.py", "_script_handlers.py", "bidi_enhancements_manifest.py", "cdp.py", diff --git a/py/private/_permissions_handlers.py b/py/private/_permissions_handlers.py new file mode 100644 index 0000000000000..c798f4d89625b --- /dev/null +++ b/py/private/_permissions_handlers.py @@ -0,0 +1,246 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""High-level permission management helpers for the WebDriver BiDi permissions module. + +This module is copied verbatim into the generated ``selenium.webdriver.common.bidi`` +package by Bazel (see ``create-bidi-src`` in ``py/BUILD.bazel``). The generated +``permissions`` module imports :class:`PermissionsManager` and instantiates it in +``__init__``, adding a convenience layer on top of the CDDL-generated +``permissions.setPermission`` command. + +:class:`PermissionsManager` keeps a client-side record of every override that has +been applied so that ``reset()`` with no descriptor argument can restore the browser +to its default ``prompt`` state without the caller having to track permission names +or origins themselves. + +:class:`PermissionOverrideContext` is returned by :meth:`PermissionsManager.override` +and implements the context-manager protocol: it applies the requested state on +``__enter__`` and resets to ``prompt`` on ``__exit__``. +""" + +from __future__ import annotations + +import logging +from typing import Any, Literal + +logger = logging.getLogger(__name__) + + +def _descriptor_name(descriptor: Any) -> str: + """Extract the permission name from a string or PermissionDescriptor.""" + if isinstance(descriptor, str): + return descriptor + return descriptor.name + + +def _is_single_descriptor(descriptor: Any) -> bool: + """Return True if *descriptor* is a single permission (str or PermissionDescriptor-like). + + Strings are iterable in Python, so a plain ``isinstance(d, Iterable)`` check + would misidentify a permission name string as a collection. We treat anything + with a ``.name`` attribute as a ``PermissionDescriptor`` and anything that is a + ``str`` as a bare permission name — both are "single" descriptors. + """ + return isinstance(descriptor, str) or hasattr(descriptor, "name") + + +class PermissionOverrideContext: + """Context manager for a temporary permission override. + + Returned by :meth:`PermissionsManager.override`; not normally instantiated + directly. The permission is applied on ``__enter__`` and reset to + ``prompt`` (browser default) on ``__exit__``, regardless of whether the + body raised an exception. + """ + + def __init__( + self, + manager: PermissionsManager, + descriptor: Any, + state: str, + *, + origin: str | None = None, + user_context: str | None = None, + ) -> None: + self._manager = manager + self._descriptor = descriptor + self._state = state + self._origin = origin + self._user_context = user_context + + def __enter__(self) -> PermissionOverrideContext: + self._manager._apply(self._descriptor, self._state, self._origin, self._user_context) + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Literal[False]: + self._manager.reset(self._descriptor, origin=self._origin, user_context=self._user_context) + return False + + +class PermissionsManager: + """Tracks and manages browser permission overrides. + + Instantiated once per ``Permissions`` module instance and stored as + ``self._manager``. All client-facing convenience methods on the generated + ``Permissions`` class delegate here. + + The manager maintains a client-side dict of active overrides keyed by + ``(permission_name, origin, user_context)``. This enables + :meth:`reset_all` to clean up every override without requiring the caller + to keep their own records. + """ + + def __init__(self, permissions_module: Any, driver: Any) -> None: + self._permissions = permissions_module + self._driver = driver + # (descriptor_name, origin, user_context) → state + self._active_overrides: dict[tuple[str, str | None, str | None], str] = {} + + def _apply( + self, + descriptor: Any, + state: str, + origin: str | None, + user_context: str | None, + ) -> None: + """Send the BiDi command and update the tracking dict.""" + self._permissions.set_permission(descriptor, state, origin=origin, user_context=user_context) + name = _descriptor_name(descriptor) + if state == "prompt": + self._active_overrides.pop((name, origin, user_context), None) + else: + self._active_overrides[(name, origin, user_context)] = state + logger.debug( + "Permission %r set to %r (origin=%r, user_context=%r)", + name, + state, + origin, + user_context, + ) + + def grant( + self, + descriptor: Any, + *, + origin: str | None = None, + user_context: str | None = None, + ) -> None: + """Grant one or more permissions. + + Args: + descriptor: A single permission name string, a single + ``PermissionDescriptor``, or an iterable of either. + origin: Optional origin to scope the grant(s) to. + user_context: Optional user context ID to scope the grant(s) to. + """ + if _is_single_descriptor(descriptor): + self._apply(descriptor, "granted", origin, user_context) + else: + for d in descriptor: + self._apply(d, "granted", origin, user_context) + + def deny( + self, + descriptor: Any, + *, + origin: str | None = None, + user_context: str | None = None, + ) -> None: + """Deny a permission. + + Args: + descriptor: The permission name string or a ``PermissionDescriptor``. + origin: Optional origin to scope the denial to. + user_context: Optional user context ID to scope the denial to. + """ + self._apply(descriptor, "denied", origin, user_context) + + def reset( + self, + descriptor: Any = None, + *, + origin: str | None = None, + user_context: str | None = None, + ) -> None: + """Reset one or more permissions to ``prompt`` (the browser default). + + Called with no positional argument, resets every tracked override back + to ``prompt`` — equivalent to the former ``reset_all()``. Only + overrides applied through the manager (``grant``, ``deny``, or + ``override``) are tracked; overrides applied directly via + ``set_permission`` are not. + + Called with a descriptor (or iterable of descriptors), resets only + those specific permissions. + + Args: + descriptor: A single permission name string, a single + ``PermissionDescriptor``, an iterable of either, or ``None`` + to reset all tracked overrides. + origin: Optional origin the override was scoped to (ignored when + resetting all). + user_context: Optional user context ID the override was scoped to + (ignored when resetting all). + """ + if descriptor is None: + for name, o, uc in list(self._active_overrides): + self._permissions.set_permission(name, "prompt", origin=o, user_context=uc) + logger.debug( + "Permission %r reset (origin=%r, user_context=%r)", + name, + o, + uc, + ) + self._active_overrides.clear() + elif _is_single_descriptor(descriptor): + self._apply(descriptor, "prompt", origin, user_context) + else: + for d in descriptor: + self._apply(d, "prompt", origin, user_context) + + def override( + self, + descriptor: Any, + state: str, + *, + origin: str | None = None, + user_context: str | None = None, + ) -> PermissionOverrideContext: + """Return a context manager that applies *state* on enter and resets on exit. + + Args: + descriptor: The permission name string or a ``PermissionDescriptor``. + state: The desired permission state (``"granted"``, ``"denied"``, or + ``"prompt"``). + origin: Optional origin to scope the override to. + user_context: Optional user context ID to scope the override to. + + Example:: + + with driver.permissions.override("geolocation", "granted", origin=origin): + # geolocation is granted inside this block + ... + # geolocation is reset to prompt here + """ + return PermissionOverrideContext( + self, + descriptor, + state, + origin=origin, + user_context=user_context, + ) diff --git a/py/private/bidi_enhancements_manifest.py b/py/private/bidi_enhancements_manifest.py index 5abbae30b8e8a..04587ce30b193 100644 --- a/py/private/bidi_enhancements_manifest.py +++ b/py/private/bidi_enhancements_manifest.py @@ -1877,7 +1877,9 @@ def remove_file_dialog_handler(self, handler_id: int) -> None: "Provides control over browser permission grants during automated tests,\n" "as specified by the W3C Permissions specification.\n\n" "Typical usage::\n\n" - " driver.permissions.set_permission('geolocation', 'granted', origin)\n" + " driver.permissions.grant('geolocation', origin=origin)\n\n" + " with driver.permissions.override('geolocation', 'granted', origin=origin):\n" + " ... # geolocation is granted; reset to prompt on exit\n" ), "class_docstrings": { "PermissionState": ( @@ -1890,6 +1892,9 @@ def remove_file_dialog_handler(self, handler_id: int) -> None: "BiDi interface for controlling browser permissions.\n\nAccess via ``driver.permissions``." ), }, + "extra_init_code": [ + "self._manager = PermissionsManager(self, self._driver)", + ], "extra_dataclasses": [ '''class PermissionDescriptor: """Descriptor identifying a permission by name. @@ -1903,15 +1908,20 @@ def __init__(self, name: str) -> None: def __repr__(self) -> str: return f"PermissionDescriptor(name={self.name!r})"''', + # PermissionsManager and PermissionOverrideContext live in the static + # helper module _permissions_handlers.py (staged via create-bidi-src + # extra_srcs) so the implementation is lintable and unit-testable as + # real code. + """from selenium.webdriver.common.bidi._permissions_handlers import PermissionsManager""", ], "extra_methods": [ ''' def set_permission( self, descriptor: "PermissionDescriptor | str", state: "PermissionState | str", + *, origin: str | None = None, user_context: str | None = None, - *, embedded_origin: str | None = None, ) -> None: """Set a browser permission. @@ -1919,8 +1929,9 @@ def __repr__(self) -> str: Args: descriptor: The permission descriptor or permission name as a string. state: The desired permission state (granted, denied, or prompt). - origin: The origin to scope the permission to. - user_context: Optional user context ID to scope the permission. + origin: Keyword-only. The origin to scope the permission to. + user_context: Keyword-only. Optional user context ID to scope the + permission. embedded_origin: Keyword-only. Embedded origin for cross-origin iframes; scopes the permission to that iframe's origin. @@ -1950,6 +1961,92 @@ def __repr__(self) -> str: cmd = command_builder("permissions.setPermission", params) self._conn.execute(cmd)''', + ''' def grant( + self, + descriptor: "PermissionDescriptor | str | list", + *, + origin: str | None = None, + user_context: str | None = None, + ) -> None: + """Grant one or more permissions. + + Each override is tracked so :meth:`reset` (with no descriptor) can + clean them all up. + + Args: + descriptor: A single permission name string, a single + ``PermissionDescriptor``, or an iterable of either. + origin: Optional origin to scope the grant(s) to. + user_context: Optional user context ID to scope the grant(s) to. + """ + self._manager.grant(descriptor, origin=origin, user_context=user_context)''', + ''' def deny( + self, + descriptor: "PermissionDescriptor | str", + *, + origin: str | None = None, + user_context: str | None = None, + ) -> None: + """Deny a permission. + + Shorthand for ``set_permission(descriptor, 'denied', ...)``. + The override is tracked so :meth:`reset` can clean it up later. + + Args: + descriptor: The permission name string or a ``PermissionDescriptor``. + origin: Optional origin to scope the denial to. + user_context: Optional user context ID to scope the denial to. + """ + self._manager.deny(descriptor, origin=origin, user_context=user_context)''', + ''' def reset( + self, + descriptor: "PermissionDescriptor | str | list | None" = None, + *, + origin: str | None = None, + user_context: str | None = None, + ) -> None: + """Reset one or more permissions to ``prompt`` (the browser default). + + Called with no positional argument, resets every tracked override. + Only overrides applied through :meth:`grant`, :meth:`deny`, or + :meth:`override` are tracked; overrides applied directly via + :meth:`set_permission` are not. + + Args: + descriptor: A single permission name string, a single + ``PermissionDescriptor``, an iterable of either, or ``None`` + (default) to reset all tracked overrides. + origin: Optional origin the override was scoped to (ignored when + resetting all). + user_context: Optional user context ID the override was scoped to + (ignored when resetting all). + """ + self._manager.reset(descriptor, origin=origin, user_context=user_context)''', + ''' def override( + self, + descriptor: "PermissionDescriptor | str", + state: "PermissionState | str", + *, + origin: str | None = None, + user_context: str | None = None, + ): + """Return a context manager that applies *state* on enter and resets on exit. + + Args: + descriptor: The permission name string or a ``PermissionDescriptor``. + state: The desired permission state (``"granted"``, ``"denied"``, or + ``"prompt"``). + origin: Optional origin to scope the override to. + user_context: Optional user context ID to scope the override to. + + Example:: + + with driver.permissions.override("geolocation", "granted", origin=origin): + # geolocation is granted inside this block + ... + # geolocation is reset to prompt here + """ + return self._manager.override(descriptor, state, origin=origin, user_context=user_context)''', ], }, "bluetooth": { diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index 232637c313ee4..30dfea890d501 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -1330,7 +1330,7 @@ def permissions(self) -> Permissions: self._start_bidi() if self._permissions is None: - self._permissions = Permissions(self._websocket_connection) + self._permissions = Permissions(self._websocket_connection, self) return self._permissions diff --git a/py/test/selenium/webdriver/common/bidi_emulation_tests.py b/py/test/selenium/webdriver/common/bidi_emulation_tests.py index 58b9c9a28c8aa..8f4b4b158de5e 100644 --- a/py/test/selenium/webdriver/common/bidi_emulation_tests.py +++ b/py/test/selenium/webdriver/common/bidi_emulation_tests.py @@ -46,7 +46,7 @@ def get_browser_timezone_offset(driver): def get_browser_geolocation(driver, user_context=None): origin = driver.execute_script("return window.location.origin;") - driver.permissions.set_permission("geolocation", PermissionState.GRANTED, origin, user_context=user_context) + driver.permissions.set_permission("geolocation", PermissionState.GRANTED, origin=origin, user_context=user_context) return driver.execute_async_script(""" const callback = arguments[arguments.length - 1]; diff --git a/py/test/selenium/webdriver/common/bidi_permissions_tests.py b/py/test/selenium/webdriver/common/bidi_permissions_tests.py index 5a65ce0afc77a..7813093ee0dc2 100644 --- a/py/test/selenium/webdriver/common/bidi_permissions_tests.py +++ b/py/test/selenium/webdriver/common/bidi_permissions_tests.py @@ -51,7 +51,7 @@ def test_can_set_permission_to_granted(driver, pages): origin = get_origin(driver) # Set geolocation permission to granted - driver.permissions.set_permission("geolocation", PermissionState.GRANTED, origin) + driver.permissions.set_permission("geolocation", PermissionState.GRANTED, origin=origin) result = get_geolocation_permission(driver) assert result == PermissionState.GRANTED @@ -64,7 +64,7 @@ def test_can_set_permission_to_denied(driver, pages): origin = get_origin(driver) # Set geolocation permission to denied - driver.permissions.set_permission("geolocation", PermissionState.DENIED, origin) + driver.permissions.set_permission("geolocation", PermissionState.DENIED, origin=origin) result = get_geolocation_permission(driver) assert result == PermissionState.DENIED @@ -77,8 +77,8 @@ def test_can_set_permission_to_prompt(driver, pages): origin = get_origin(driver) # First set to denied, then to prompt since most of the time the default state is prompt - driver.permissions.set_permission("geolocation", PermissionState.DENIED, origin) - driver.permissions.set_permission("geolocation", PermissionState.PROMPT, origin) + driver.permissions.set_permission("geolocation", PermissionState.DENIED, origin=origin) + driver.permissions.set_permission("geolocation", PermissionState.PROMPT, origin=origin) result = get_geolocation_permission(driver) assert result == PermissionState.PROMPT @@ -107,7 +107,7 @@ def test_can_set_permission_for_user_context(driver, pages): # Set permission only for the user context using PermissionDescriptor descriptor = PermissionDescriptor("geolocation") - driver.permissions.set_permission(descriptor, PermissionState.GRANTED, origin, user_context) + driver.permissions.set_permission(descriptor, PermissionState.GRANTED, origin=origin, user_context=user_context) # Check that the original window's permission hasn't changed driver.switch_to.window(original_window) @@ -132,7 +132,7 @@ def test_invalid_permission_state_raises_error(driver, pages): descriptor = PermissionDescriptor("geolocation") with pytest.raises(ValueError, match="Invalid permission state"): - driver.permissions.set_permission(descriptor, "invalid_state", origin) + driver.permissions.set_permission(descriptor, "invalid_state", origin=origin) def test_permission_states_constants(): @@ -142,25 +142,18 @@ def test_permission_states_constants(): assert PermissionState.PROMPT == "prompt" -def test_embedded_origin_is_keyword_only(driver, pages): - """Verify embedded_origin cannot be passed positionally (backwards compat guard). +def test_scoping_args_are_keyword_only(driver, pages): + """Verify origin, user_context, and embedded_origin are all keyword-only. - Existing call sites use set_permission(descriptor, state, origin, user_context) - as four positional args. embedded_origin must not occupy that 4th slot. + Only descriptor and state may be passed positionally; everything that scopes + the permission must be a keyword argument, matching the grant/deny/reset API. """ pages.load("blank.html") origin = get_origin(driver) - user_context = driver.browser.create_user_context() - - try: - # This is the pre-existing positional call pattern — must still work - driver.permissions.set_permission("geolocation", PermissionState.DENIED, origin, user_context) - finally: - driver.browser.remove_user_context(user_context) - # embedded_origin is keyword-only — a 5th positional arg must raise TypeError + # origin passed positionally must raise TypeError with pytest.raises(TypeError): - driver.permissions.set_permission("geolocation", PermissionState.DENIED, origin, None, origin) + driver.permissions.set_permission("geolocation", PermissionState.DENIED, origin) def test_can_set_permission_with_embedded_origin(driver, pages): @@ -177,9 +170,176 @@ def test_can_set_permission_with_embedded_origin(driver, pages): driver.permissions.set_permission( "geolocation", PermissionState.GRANTED, - origin, + origin=origin, embedded_origin=origin, ) result = get_geolocation_permission(driver) assert result == PermissionState.GRANTED + + +# --------------------------------------------------------------------------- +# Convenience methods: grant / deny / reset +# --------------------------------------------------------------------------- + + +def test_grant_sets_permission_to_granted(driver, pages): + """Test that grant() sets a permission to the granted state.""" + pages.load("blank.html") + origin = get_origin(driver) + + driver.permissions.grant("geolocation", origin=origin) + + assert get_geolocation_permission(driver) == PermissionState.GRANTED + + +def test_deny_sets_permission_to_denied(driver, pages): + """Test that deny() sets a permission to the denied state.""" + pages.load("blank.html") + origin = get_origin(driver) + + driver.permissions.deny("geolocation", origin=origin) + + assert get_geolocation_permission(driver) == PermissionState.DENIED + + +def test_reset_restores_prompt(driver, pages): + """Test that reset() with a descriptor restores the permission to prompt.""" + pages.load("blank.html") + origin = get_origin(driver) + + driver.permissions.deny("geolocation", origin=origin) + driver.permissions.reset("geolocation", origin=origin) + + assert get_geolocation_permission(driver) == PermissionState.PROMPT + + +def test_grant_with_list_grants_multiple_permissions(driver, pages): + """Test that grant() with a list grants all listed permissions.""" + pages.load("blank.html") + origin = get_origin(driver) + + driver.permissions.grant(["geolocation", "notifications"], origin=origin) + + assert get_geolocation_permission(driver) == PermissionState.GRANTED + + +def test_grant_with_permission_descriptor(driver, pages): + """Test that grant() accepts a PermissionDescriptor as well as a string.""" + pages.load("blank.html") + origin = get_origin(driver) + + driver.permissions.grant(PermissionDescriptor("geolocation"), origin=origin) + + assert get_geolocation_permission(driver) == PermissionState.GRANTED + + +def test_grant_with_user_context(driver, pages): + """Test that grant() with user_context scopes the override to that context only.""" + user_context = driver.browser.create_user_context() + context_id = driver.browsing_context.create(type=WindowTypes.TAB, user_context=user_context) + + pages.load("blank.html") + original_window = driver.current_window_handle + driver.switch_to.window(context_id) + pages.load("blank.html") + origin = get_origin(driver) + + driver.switch_to.window(original_window) + original_permission = get_geolocation_permission(driver) + + driver.permissions.grant("geolocation", origin=origin, user_context=user_context) + + driver.switch_to.window(original_window) + assert get_geolocation_permission(driver) == original_permission + + driver.switch_to.window(context_id) + assert get_geolocation_permission(driver) == PermissionState.GRANTED + + driver.browsing_context.close(context_id) + driver.browser.remove_user_context(user_context) + + +# --------------------------------------------------------------------------- +# reset (no-arg and list forms) +# --------------------------------------------------------------------------- + + +def test_reset_with_no_args_clears_all_tracked_overrides(driver, pages): + """Test that reset() with no argument resets all overrides applied via grant/deny.""" + pages.load("blank.html") + origin = get_origin(driver) + + driver.permissions.grant("geolocation", origin=origin) + assert get_geolocation_permission(driver) == PermissionState.GRANTED + + driver.permissions.reset() + + assert get_geolocation_permission(driver) == PermissionState.PROMPT + + +def test_reset_with_list_resets_multiple_permissions(driver, pages): + """Test that reset() with a list resets each listed permission to prompt.""" + pages.load("blank.html") + origin = get_origin(driver) + + driver.permissions.grant("geolocation", origin=origin) + driver.permissions.reset(["geolocation"], origin=origin) + + assert get_geolocation_permission(driver) == PermissionState.PROMPT + + +def test_reset_no_args_only_affects_tracked_overrides(driver, pages): + """Test that reset() does not disturb permissions set via set_permission directly.""" + pages.load("blank.html") + origin = get_origin(driver) + + # set_permission is not tracked by the manager + driver.permissions.set_permission("geolocation", PermissionState.GRANTED, origin=origin) + + # reset() with no args is a no-op here (nothing tracked) + driver.permissions.reset() + + # The permission set via set_permission is unaffected + assert get_geolocation_permission(driver) == PermissionState.GRANTED + + +# --------------------------------------------------------------------------- +# override context manager +# --------------------------------------------------------------------------- + + +def test_override_grants_within_block_and_resets_after(driver, pages): + """Test that override() applies the state on enter and resets to prompt on exit.""" + pages.load("blank.html") + origin = get_origin(driver) + + with driver.permissions.override("geolocation", "granted", origin=origin): + assert get_geolocation_permission(driver) == PermissionState.GRANTED + + assert get_geolocation_permission(driver) == PermissionState.PROMPT + + +def test_override_resets_even_after_exception(driver, pages): + """Test that override() resets the permission even when the body raises.""" + pages.load("blank.html") + origin = get_origin(driver) + + try: + with driver.permissions.override("geolocation", "granted", origin=origin): + raise RuntimeError("simulated failure") + except RuntimeError: + pass + + assert get_geolocation_permission(driver) == PermissionState.PROMPT + + +def test_override_with_permission_descriptor(driver, pages): + """Test that override() accepts a PermissionDescriptor as well as a string.""" + pages.load("blank.html") + origin = get_origin(driver) + + with driver.permissions.override(PermissionDescriptor("geolocation"), "denied", origin=origin): + assert get_geolocation_permission(driver) == PermissionState.DENIED + + assert get_geolocation_permission(driver) == PermissionState.PROMPT diff --git a/py/test/unit/selenium/webdriver/common/bidi_permissions_tests.py b/py/test/unit/selenium/webdriver/common/bidi_permissions_tests.py new file mode 100644 index 0000000000000..9faeca4458204 --- /dev/null +++ b/py/test/unit/selenium/webdriver/common/bidi_permissions_tests.py @@ -0,0 +1,311 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import pytest + +from selenium.webdriver.common.bidi._permissions_handlers import PermissionOverrideContext +from selenium.webdriver.common.bidi.permissions import PermissionDescriptor, Permissions, PermissionState + + +class FakeConnection: + def __init__(self): + self.commands = [] + + def execute(self, cmd): + payload = next(cmd) + self.commands.append(payload) + try: + cmd.send({}) + except StopIteration as exc: + return exc.value + raise AssertionError("BiDi command generator did not finish") + + def commands_named(self, method): + return [c for c in self.commands if c["method"] == method] + + def last_set_permission(self): + cmds = self.commands_named("permissions.setPermission") + return cmds[-1]["params"] if cmds else None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_permissions(conn=None): + """Return a Permissions instance backed by *conn* (creates a new one if omitted).""" + if conn is None: + conn = FakeConnection() + return Permissions(conn, driver=None), conn + + +# --------------------------------------------------------------------------- +# PermissionsManager unit tests (via the full Permissions class) +# --------------------------------------------------------------------------- + + +class TestGrant: + def test_single_string(self): + perms, conn = make_permissions() + perms.grant("geolocation", origin="https://example.com") + params = conn.last_set_permission() + assert params["state"] == "granted" + assert params["descriptor"] == {"name": "geolocation"} + assert params["origin"] == "https://example.com" + + def test_single_permission_descriptor(self): + perms, conn = make_permissions() + perms.grant(PermissionDescriptor("camera"), origin="https://example.com") + params = conn.last_set_permission() + assert params["descriptor"] == {"name": "camera"} + assert params["state"] == "granted" + + def test_list_of_strings(self): + perms, conn = make_permissions() + perms.grant(["geolocation", "camera"], origin="https://example.com") + cmds = conn.commands_named("permissions.setPermission") + names = {c["params"]["descriptor"]["name"] for c in cmds} + assert names == {"geolocation", "camera"} + assert all(c["params"]["state"] == "granted" for c in cmds) + + def test_list_of_permission_descriptors(self): + perms, conn = make_permissions() + perms.grant([PermissionDescriptor("geolocation"), PermissionDescriptor("camera")]) + cmds = conn.commands_named("permissions.setPermission") + names = {c["params"]["descriptor"]["name"] for c in cmds} + assert names == {"geolocation", "camera"} + + def test_list_scoped_to_same_origin(self): + perms, conn = make_permissions() + perms.grant(["geolocation", "camera"], origin="https://example.com") + cmds = conn.commands_named("permissions.setPermission") + assert all(c["params"]["origin"] == "https://example.com" for c in cmds) + + def test_origin_is_optional(self): + perms, conn = make_permissions() + perms.grant("notifications") + params = conn.last_set_permission() + assert params["state"] == "granted" + assert "origin" not in params + + def test_user_context_is_optional(self): + perms, conn = make_permissions() + perms.grant("geolocation", user_context="ctx-1") + params = conn.last_set_permission() + assert params["userContext"] == "ctx-1" + assert "origin" not in params + + def test_tracks_single_override(self): + perms, conn = make_permissions() + perms.grant("geolocation", origin="https://example.com") + assert ("geolocation", "https://example.com", None) in perms._manager._active_overrides + + def test_tracks_each_override_in_list(self): + perms, conn = make_permissions() + perms.grant(["geolocation", "camera"], origin="https://example.com") + assert ("geolocation", "https://example.com", None) in perms._manager._active_overrides + assert ("camera", "https://example.com", None) in perms._manager._active_overrides + + +class TestDeny: + def test_sends_denied_state(self): + perms, conn = make_permissions() + perms.deny("camera", origin="https://example.com") + params = conn.last_set_permission() + assert params["state"] == "denied" + assert params["descriptor"] == {"name": "camera"} + + def test_origin_is_optional(self): + perms, conn = make_permissions() + perms.deny("microphone") + params = conn.last_set_permission() + assert params["state"] == "denied" + assert "origin" not in params + + def test_tracks_override(self): + perms, conn = make_permissions() + perms.deny("camera", origin="https://example.com") + assert ("camera", "https://example.com", None) in perms._manager._active_overrides + + +class TestReset: + def test_single_string(self): + perms, conn = make_permissions() + perms.grant("geolocation", origin="https://example.com") + perms.reset("geolocation", origin="https://example.com") + params = conn.last_set_permission() + assert params["state"] == "prompt" + + def test_list_of_strings(self): + perms, conn = make_permissions() + perms.grant(["geolocation", "camera"], origin="https://example.com") + conn.commands.clear() + perms.reset(["geolocation", "camera"], origin="https://example.com") + cmds = conn.commands_named("permissions.setPermission") + assert len(cmds) == 2 + assert all(c["params"]["state"] == "prompt" for c in cmds) + + def test_no_args_resets_all_tracked(self): + perms, conn = make_permissions() + perms.grant("geolocation", origin="https://a.com") + perms.deny("camera", origin="https://b.com") + conn.commands.clear() + + perms.reset() + + set_cmds = conn.commands_named("permissions.setPermission") + states = {c["params"]["descriptor"]["name"]: c["params"]["state"] for c in set_cmds} + assert states["geolocation"] == "prompt" + assert states["camera"] == "prompt" + + def test_no_args_clears_tracking_dict(self): + perms, conn = make_permissions() + perms.grant("geolocation", origin="https://a.com") + perms.deny("camera") + perms.reset() + assert perms._manager._active_overrides == {} + + def test_no_args_with_no_overrides_is_safe(self): + perms, conn = make_permissions() + perms.reset() + assert conn.commands_named("permissions.setPermission") == [] + + def test_removes_from_tracking(self): + perms, conn = make_permissions() + perms.grant("geolocation", origin="https://example.com") + perms.reset("geolocation", origin="https://example.com") + assert ("geolocation", "https://example.com", None) not in perms._manager._active_overrides + + def test_reset_untracked_permission_is_safe(self): + perms, conn = make_permissions() + perms.reset("geolocation", origin="https://example.com") + params = conn.last_set_permission() + assert params["state"] == "prompt" + + +class TestOverrideContextManager: + def test_sets_permission_on_enter(self): + perms, conn = make_permissions() + with perms.override("geolocation", "granted", origin="https://example.com"): + params = conn.last_set_permission() + assert params["state"] == "granted" + + def test_resets_to_prompt_on_exit(self): + perms, conn = make_permissions() + with perms.override("geolocation", "granted", origin="https://example.com"): + pass + params = conn.last_set_permission() + assert params["state"] == "prompt" + + def test_resets_on_exception(self): + perms, conn = make_permissions() + with pytest.raises(RuntimeError): + with perms.override("geolocation", "granted", origin="https://example.com"): + raise RuntimeError("test error") + params = conn.last_set_permission() + assert params["state"] == "prompt" + + def test_returns_context_object(self): + perms, conn = make_permissions() + with perms.override("geolocation", "denied") as ctx: + assert isinstance(ctx, PermissionOverrideContext) + + def test_origin_and_user_context_are_optional(self): + perms, conn = make_permissions() + with perms.override("geolocation", "granted"): + params = conn.last_set_permission() + assert params["state"] == "granted" + assert "origin" not in params + params = conn.last_set_permission() + assert params["state"] == "prompt" + + def test_does_not_track_after_exit(self): + perms, conn = make_permissions() + with perms.override("geolocation", "granted", origin="https://example.com"): + pass + assert ("geolocation", "https://example.com", None) not in perms._manager._active_overrides + + +# --------------------------------------------------------------------------- +# set_permission +# --------------------------------------------------------------------------- + + +class TestSetPermission: + def test_invalid_state_raises(self): + perms, conn = make_permissions() + with pytest.raises(ValueError, match="Invalid permission state"): + perms.set_permission("geolocation", "invalid", origin="https://example.com") + + def test_accepts_permission_descriptor(self): + perms, conn = make_permissions() + perms.set_permission(PermissionDescriptor("geolocation"), PermissionState.GRANTED, origin="https://example.com") + params = conn.last_set_permission() + assert params["descriptor"] == {"name": "geolocation"} + assert params["state"] == "granted" + + def test_origin_is_keyword_only(self): + perms, conn = make_permissions() + with pytest.raises(TypeError): + perms.set_permission("geolocation", "granted", "https://example.com") + + def test_user_context_is_keyword_only(self): + perms, conn = make_permissions() + with pytest.raises(TypeError): + perms.set_permission("geolocation", "granted", "https://example.com", "ctx-1") + + def test_scoping_args_accepted_as_keywords(self): + perms, conn = make_permissions() + perms.set_permission( + "geolocation", + "granted", + origin="https://example.com", + user_context="ctx-1", + embedded_origin="https://embedded.example.com", + ) + params = conn.last_set_permission() + assert params["origin"] == "https://example.com" + assert params["userContext"] == "ctx-1" + assert params["embeddedOrigin"] == "https://embedded.example.com" + + +# --------------------------------------------------------------------------- +# PermissionDescriptor +# --------------------------------------------------------------------------- + + +class TestPermissionDescriptor: + def test_stores_name(self): + d = PermissionDescriptor("geolocation") + assert d.name == "geolocation" + + def test_repr(self): + d = PermissionDescriptor("camera") + assert repr(d) == "PermissionDescriptor(name='camera')" + + +# --------------------------------------------------------------------------- +# PermissionState constants +# --------------------------------------------------------------------------- + + +class TestPermissionState: + def test_constants(self): + assert PermissionState.GRANTED == "granted" + assert PermissionState.DENIED == "denied" + assert PermissionState.PROMPT == "prompt"