diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index cfd0a97..3bb8081 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -6684,7 +6684,7 @@ multidict implementation ``` -## nicegui (3.9.0) - UNKNOWN +## nicegui (3.10.0) - UNKNOWN Create web-based user interfaces with Python. The nice way. diff --git a/src/aignostics_foundry_core/gui/auth.py b/src/aignostics_foundry_core/gui/auth.py index bf498e1..bc2f762 100644 --- a/src/aignostics_foundry_core/gui/auth.py +++ b/src/aignostics_foundry_core/gui/auth.py @@ -42,7 +42,7 @@ def dashboard(user: dict) -> None: import contextlib import inspect import time -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Generator from dataclasses import dataclass from enum import StrEnum from typing import Any @@ -51,6 +51,7 @@ def dashboard(user: dict) -> None: from loguru import logger from aignostics_foundry_core.api.auth import AUTH0_ROLE_ADMIN, AuthSettings, get_auth_client +from aignostics_foundry_core.foundry import get_context from aignostics_foundry_core.sentry import set_sentry_user from aignostics_foundry_core.settings import load_settings @@ -81,7 +82,7 @@ class _PageEntry: access: AccessLevel path: str - title: str + title: str | None # None → resolved at request time from get_context().name.title() func: Callable[..., Any] @@ -218,9 +219,22 @@ async def require_gui_user(request: Request, return_to: str | None = None) -> di # --------------------------------------------------------------------------- +@contextlib.contextmanager +def _frame_context( + frame_func: FrameFunc, + title: str, + user: dict[str, Any] | None, +) -> Generator[None, None, None]: + if frame_func is not None: + with frame_func(title, user=user): + yield + else: + yield + + def _actualize_public( path: str, - title: str = "", + title: str | None = None, frame_func: FrameFunc = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Register a public NiceGUI page immediately with the given frame_func. @@ -235,8 +249,9 @@ def decorator( ) -> Callable[[Request], Awaitable[None]]: @ui.page(path, response_timeout=RESPONSE_TIMEOUT) async def wrapper(request: Request) -> None: + resolved_title = title if title is not None else get_context().name.title() user = await get_gui_user(request) - with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): + with _frame_context(frame_func, resolved_title, user): await _invoke_page_func(func, user) wrapper.__name__ = func.__name__ @@ -250,7 +265,7 @@ async def wrapper(request: Request) -> None: def _actualize_authenticated( path: str, - title: str = "", + title: str | None = None, frame_func: FrameFunc = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Register an authenticated NiceGUI page immediately with the given frame_func. @@ -265,10 +280,11 @@ def decorator( ) -> Callable[[Request], Awaitable[None]]: @ui.page(path, response_timeout=RESPONSE_TIMEOUT) async def wrapper(request: Request) -> None: + resolved_title = title if title is not None else get_context().name.title() user = await require_gui_user(request) if not user: return - with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): + with _frame_context(frame_func, resolved_title, user): await _invoke_page_func(func, user) wrapper.__name__ = func.__name__ @@ -282,7 +298,7 @@ async def wrapper(request: Request) -> None: def _actualize_admin( path: str, - title: str = "", + title: str | None = None, frame_func: FrameFunc = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Register an admin-only NiceGUI page immediately with the given frame_func. @@ -297,6 +313,7 @@ def decorator( ) -> Callable[[Request], Awaitable[None]]: @ui.page(path, response_timeout=RESPONSE_TIMEOUT) async def wrapper(request: Request) -> None: + resolved_title = title if title is not None else get_context().name.title() user = await require_gui_user(request) if not user: return @@ -304,11 +321,11 @@ async def wrapper(request: Request) -> None: auth_settings = load_settings(AuthSettings) role = user.get(auth_settings.auth0_role_claim) if role != AUTH0_ROLE_ADMIN: - with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): + with _frame_context(frame_func, resolved_title, user): ui.label(f"{MSG_403_FORBIDDEN} - Admin access required").classes(CLASS_FORBIDDEN_ERROR) return - with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): + with _frame_context(frame_func, resolved_title, user): await _invoke_page_func(func, user) wrapper.__name__ = func.__name__ @@ -322,7 +339,7 @@ async def wrapper(request: Request) -> None: def _actualize_internal( path: str, - title: str = "", + title: str | None = None, frame_func: FrameFunc = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Register an internal-org-only NiceGUI page immediately with the given frame_func. @@ -337,6 +354,7 @@ def decorator( ) -> Callable[[Request], Awaitable[None]]: @ui.page(path, response_timeout=RESPONSE_TIMEOUT) async def wrapper(request: Request) -> None: + resolved_title = title if title is not None else get_context().name.title() user = await require_gui_user(request) if not user: return @@ -344,11 +362,11 @@ async def wrapper(request: Request) -> None: auth_settings = load_settings(AuthSettings) org_id = user.get("org_id") if org_id != auth_settings.internal_org_id: - with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): + with _frame_context(frame_func, resolved_title, user): ui.label(f"{MSG_403_FORBIDDEN} - Internal access required").classes(CLASS_FORBIDDEN_ERROR) return - with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): + with _frame_context(frame_func, resolved_title, user): await _invoke_page_func(func, user) wrapper.__name__ = func.__name__ @@ -362,7 +380,7 @@ async def wrapper(request: Request) -> None: def _actualize_internal_admin( path: str, - title: str = "", + title: str | None = None, frame_func: FrameFunc = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Register an internal-org admin-only NiceGUI page immediately with the given frame_func. @@ -377,6 +395,7 @@ def decorator( ) -> Callable[[Request], Awaitable[None]]: @ui.page(path, response_timeout=RESPONSE_TIMEOUT) async def wrapper(request: Request) -> None: + resolved_title = title if title is not None else get_context().name.title() user = await require_gui_user(request) if not user: return @@ -386,11 +405,11 @@ async def wrapper(request: Request) -> None: role = user.get(auth_settings.auth0_role_claim) if org_id != auth_settings.internal_org_id or role != AUTH0_ROLE_ADMIN: - with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): + with _frame_context(frame_func, resolved_title, user): ui.label(f"{MSG_403_FORBIDDEN} - Internal admin access required").classes(CLASS_FORBIDDEN_ERROR) return - with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): + with _frame_context(frame_func, resolved_title, user): await _invoke_page_func(func, user) wrapper.__name__ = func.__name__ @@ -408,7 +427,7 @@ async def wrapper(request: Request) -> None: # --------------------------------------------------------------------------- -def page_public(path: str, title: str = "") -> Callable[..., Any]: +def page_public(path: str, title: str | None = None) -> Callable[..., Any]: """Decorator that registers a public page in the global registry. The route is NOT registered with NiceGUI immediately. Call @@ -418,6 +437,7 @@ def page_public(path: str, title: str = "") -> Callable[..., Any]: Args: path: The URL path for the page. title: The title passed to the frame function when the page is actualized. + Defaults to the project name from ``get_context()`` when omitted. Returns: A decorator that records the page function in the registry and returns @@ -437,7 +457,7 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: return decorator -def page_authenticated(path: str, title: str = "") -> Callable[..., Any]: +def page_authenticated(path: str, title: str | None = None) -> Callable[..., Any]: """Decorator that registers an authenticated page in the global registry. The route is NOT registered with NiceGUI immediately. Call @@ -447,6 +467,7 @@ def page_authenticated(path: str, title: str = "") -> Callable[..., Any]: Args: path: The URL path for the page. title: The title passed to the frame function when the page is actualized. + Defaults to the project name from ``get_context()`` when omitted. Returns: A decorator that records the page function in the registry and returns @@ -466,7 +487,7 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: return decorator -def page_admin(path: str, title: str = "") -> Callable[..., Any]: +def page_admin(path: str, title: str | None = None) -> Callable[..., Any]: """Decorator that registers an admin-only page in the global registry. The route is NOT registered with NiceGUI immediately. Call @@ -476,6 +497,7 @@ def page_admin(path: str, title: str = "") -> Callable[..., Any]: Args: path: The URL path for the page. title: The title passed to the frame function when the page is actualized. + Defaults to the project name from ``get_context()`` when omitted. Returns: A decorator that records the page function in the registry and returns @@ -495,7 +517,7 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: return decorator -def page_internal(path: str, title: str = "") -> Callable[..., Any]: +def page_internal(path: str, title: str | None = None) -> Callable[..., Any]: """Decorator that registers an internal-org-only page in the global registry. The route is NOT registered with NiceGUI immediately. Call @@ -505,6 +527,7 @@ def page_internal(path: str, title: str = "") -> Callable[..., Any]: Args: path: The URL path for the page. title: The title passed to the frame function when the page is actualized. + Defaults to the project name from ``get_context()`` when omitted. Returns: A decorator that records the page function in the registry and returns @@ -524,7 +547,7 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: return decorator -def page_internal_admin(path: str, title: str = "") -> Callable[..., Any]: +def page_internal_admin(path: str, title: str | None = None) -> Callable[..., Any]: """Decorator that registers an internal-org admin-only page in the global registry. The route is NOT registered with NiceGUI immediately. Call @@ -534,6 +557,7 @@ def page_internal_admin(path: str, title: str = "") -> Callable[..., Any]: Args: path: The URL path for the page. title: The title passed to the frame function when the page is actualized. + Defaults to the project name from ``get_context()`` when omitted. Returns: A decorator that records the page function in the registry and returns @@ -596,7 +620,7 @@ def __init__(self, frame_func: FrameFunc = None) -> None: def public( self, path: str, - title: str = "", + title: str | None = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Decorator for public NiceGUI pages. @@ -606,6 +630,7 @@ def public( Args: path: The URL path for the page. title: The title passed to the frame function (if configured). + Defaults to the project name from ``get_context()`` when omitted. Returns: A decorator that wraps the page function. @@ -615,7 +640,7 @@ def public( def authenticated( self, path: str, - title: str = "", + title: str | None = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Decorator for authenticated NiceGUI pages. @@ -625,6 +650,7 @@ def authenticated( Args: path: The URL path for the page. title: The title passed to the frame function (if configured). + Defaults to the project name from ``get_context()`` when omitted. Returns: A decorator that wraps the page function. @@ -634,7 +660,7 @@ def authenticated( def admin( self, path: str, - title: str = "", + title: str | None = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Decorator for admin-only NiceGUI pages. @@ -644,6 +670,7 @@ def admin( Args: path: The URL path for the page. title: The title passed to the frame function (if configured). + Defaults to the project name from ``get_context()`` when omitted. Returns: A decorator that wraps the page function. @@ -653,7 +680,7 @@ def admin( def internal( self, path: str, - title: str = "", + title: str | None = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Decorator for internal-org-only NiceGUI pages. @@ -663,6 +690,7 @@ def internal( Args: path: The URL path for the page. title: The title passed to the frame function (if configured). + Defaults to the project name from ``get_context()`` when omitted. Returns: A decorator that wraps the page function. @@ -672,7 +700,7 @@ def internal( def internal_admin( self, path: str, - title: str = "", + title: str | None = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Decorator for internal-org admin-only NiceGUI pages. @@ -682,6 +710,7 @@ def internal_admin( Args: path: The URL path for the page. title: The title passed to the frame function (if configured). + Defaults to the project name from ``get_context()`` when omitted. Returns: A decorator that wraps the page function. diff --git a/tests/aignostics_foundry_core/api/auth_test.py b/tests/aignostics_foundry_core/api/auth_test.py index 94f1f09..65eac6e 100644 --- a/tests/aignostics_foundry_core/api/auth_test.py +++ b/tests/aignostics_foundry_core/api/auth_test.py @@ -19,9 +19,7 @@ require_internal, require_internal_admin, ) -from aignostics_foundry_core.foundry import reset_context, set_context from tests.aignostics_foundry_core.api import AUTH0_ROLE_CLAIM_VAR_NAME, INTERNAL_ORG_ID_VAR_NAME -from tests.conftest import make_context _INTERNAL_ORG_ID = "org_internal_123" _OTHER_ORG_ID = "org_other_456" @@ -38,13 +36,11 @@ def _auth_context() -> Generator[None, None, None]: # pyright: ignore[reportUnu Yields: None """ - set_context(make_context()) os.environ[INTERNAL_ORG_ID_VAR_NAME] = _INTERNAL_ORG_ID os.environ[AUTH0_ROLE_CLAIM_VAR_NAME] = _TEST_ROLE_CLAIM yield os.environ.pop(INTERNAL_ORG_ID_VAR_NAME, None) os.environ.pop(AUTH0_ROLE_CLAIM_VAR_NAME, None) - reset_context() @pytest.mark.unit diff --git a/tests/aignostics_foundry_core/boot_test.py b/tests/aignostics_foundry_core/boot_test.py index a05ccae..852839f 100644 --- a/tests/aignostics_foundry_core/boot_test.py +++ b/tests/aignostics_foundry_core/boot_test.py @@ -11,7 +11,6 @@ import pytest import aignostics_foundry_core.boot as boot_mod -from aignostics_foundry_core.foundry import set_context from tests.conftest import TEST_PROJECT_NAME, TEST_PROJECT_PREFIX, make_context _OTHER_PROJECT = "otherapp" @@ -93,7 +92,6 @@ def test_boot_uses_global_context_when_none_provided(monkeypatch: pytest.MonkeyP monkeypatch.setattr(boot_mod, "truststore", None) monkeypatch.setattr(boot_mod, "certifi", None) - set_context(make_context()) boot_mod.boot(sentry_integrations=None) call_ctx = mock_logging.call_args.kwargs["context"] @@ -110,7 +108,6 @@ def test_boot_explicit_context_overrides_global(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr(boot_mod, "truststore", None) monkeypatch.setattr(boot_mod, "certifi", None) - set_context(make_context()) explicit_ctx = make_context(_OTHER_PROJECT) boot_mod.boot(context=explicit_ctx, sentry_integrations=None) diff --git a/tests/aignostics_foundry_core/console_test.py b/tests/aignostics_foundry_core/console_test.py index fa26610..5e59151 100644 --- a/tests/aignostics_foundry_core/console_test.py +++ b/tests/aignostics_foundry_core/console_test.py @@ -2,13 +2,12 @@ import importlib import sys -from collections.abc import Generator import pytest from rich.console import Console from aignostics_foundry_core.console import console -from aignostics_foundry_core.foundry import reset_context, set_context +from aignostics_foundry_core.foundry import set_context from tests.conftest import make_context EXPECTED_THEME_KEYS = ["success", "info", "warning", "error", "debug", "logging.level.info"] @@ -17,18 +16,6 @@ CONSOLE_MODULE = "aignostics_foundry_core.console" -@pytest.fixture(autouse=True) -def _reset_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] - """Reset global _context to None before and after every test. - - Yields: - None - """ - reset_context() - yield - reset_context() - - class TestConsole: """Tests for the themed rich console module.""" diff --git a/tests/aignostics_foundry_core/database_settings_test.py b/tests/aignostics_foundry_core/database_settings_test.py index 2802caf..95975e2 100644 --- a/tests/aignostics_foundry_core/database_settings_test.py +++ b/tests/aignostics_foundry_core/database_settings_test.py @@ -1,6 +1,5 @@ """Tests for DatabaseSettings.""" -from collections.abc import Generator from pathlib import Path import pytest @@ -28,14 +27,6 @@ TEST_DB_NAME_ENV = "TEST_DB_NAME" -@pytest.fixture(autouse=True) -def _reset_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] - """Reset global context before and after every test.""" - reset_context() - yield - reset_context() - - # --------------------------------------------------------------------------- # get_url behaviour # --------------------------------------------------------------------------- @@ -201,5 +192,6 @@ def test_database_settings_explicit_env_file_overrides_context(tmp_path: Path) - @pytest.mark.integration def test_database_settings_no_context_raises_without_prefix() -> None: """DatabaseSettings() raises RuntimeError when no context is installed and no prefix is given.""" + reset_context() with pytest.raises(RuntimeError, match="get_context\\(\\) called before set_context"): DatabaseSettings() diff --git a/tests/aignostics_foundry_core/foundry_test.py b/tests/aignostics_foundry_core/foundry_test.py index f2d3b1a..8828f1f 100644 --- a/tests/aignostics_foundry_core/foundry_test.py +++ b/tests/aignostics_foundry_core/foundry_test.py @@ -7,7 +7,6 @@ import subprocess import sys import textwrap -from collections.abc import Generator from importlib.machinery import ModuleSpec from pathlib import Path @@ -109,18 +108,6 @@ def test_from_package_metadata_is_package_metadata_instance() -> None: assert ctx.metadata == PackageMetadata.from_name(PACKAGE_NAME) -@pytest.fixture(autouse=True) -def _reset_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] - """Reset global _context to None before and after every test. - - Yields: - None - """ - reset_context() - yield - reset_context() - - # --------------------------------------------------------------------------- # from_package — name and version # --------------------------------------------------------------------------- @@ -471,6 +458,7 @@ def test_set_context_makes_context_accessible() -> None: @pytest.mark.unit def test_context_raises_before_set_context() -> None: """get_context() before set_context() raises RuntimeError.""" + reset_context() with pytest.raises(RuntimeError, match=ERROR_MSG_FRAGMENT): get_context() diff --git a/tests/aignostics_foundry_core/gui/conftest.py b/tests/aignostics_foundry_core/gui/conftest.py new file mode 100644 index 0000000..c2a9770 --- /dev/null +++ b/tests/aignostics_foundry_core/gui/conftest.py @@ -0,0 +1,30 @@ +"""Shared fixtures for GUI tests.""" + +import os +from collections.abc import Generator + +import pytest + +from aignostics_foundry_core.gui import clear_page_registry +from tests.aignostics_foundry_core.api import AUTH0_ROLE_CLAIM_VAR_NAME, INTERNAL_ORG_ID_VAR_NAME + +_INTERNAL_ORG = "org_internal" +_ROLE_CLAIM = "https://example.com/role" + + +@pytest.fixture(autouse=True) +def _clear_registry() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] + """Ensure the page registry is clean before and after each test.""" + clear_page_registry() + yield + clear_page_registry() + + +@pytest.fixture(autouse=True) +def _gui_auth_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] + """Set required AuthSettings environment variables for GUI auth tests.""" + os.environ[INTERNAL_ORG_ID_VAR_NAME] = _INTERNAL_ORG + os.environ[AUTH0_ROLE_CLAIM_VAR_NAME] = _ROLE_CLAIM + yield + os.environ.pop(INTERNAL_ORG_ID_VAR_NAME, None) + os.environ.pop(AUTH0_ROLE_CLAIM_VAR_NAME, None) diff --git a/tests/aignostics_foundry_core/gui/gui_test.py b/tests/aignostics_foundry_core/gui/gui_test.py index 93e31b7..3a91d5f 100644 --- a/tests/aignostics_foundry_core/gui/gui_test.py +++ b/tests/aignostics_foundry_core/gui/gui_test.py @@ -1,17 +1,14 @@ """Tests for aignostics_foundry_core.gui.*.""" import asyncio -import os import sys import time -from collections.abc import Generator from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest -from aignostics_foundry_core.foundry import reset_context, set_context from aignostics_foundry_core.gui.core import ( BROWSER_RECONNECT_TIMEOUT, RESPONSE_TIMEOUT, @@ -25,18 +22,18 @@ NavItem, gui_get_nav_groups, ) -from tests.aignostics_foundry_core.api import AUTH0_ROLE_CLAIM_VAR_NAME, INTERNAL_ORG_ID_VAR_NAME from tests.conftest import TEST_PROJECT_NAME, make_context _PATCH_GET_GUI_USER = "aignostics_foundry_core.gui.auth.get_gui_user" _PATCH_REQUIRE_GUI_USER = "aignostics_foundry_core.gui.auth.require_gui_user" +_PATCH_LOAD_SETTINGS = "aignostics_foundry_core.gui.auth.load_settings" _PATH_NAV_LOCATE = "aignostics_foundry_core.gui.nav.locate_subclasses" _PATH_CORE_LOCATE = "aignostics_foundry_core.gui.core.locate_subclasses" _TEST_PATH = "/test-page" -_INTERNAL_ORG = "org_internal" _OTHER_ORG = "org_other" -_ROLE_CLAIM = "https://example.com/role" +_INTERNAL_ORG_ID = "org_internal_test" +_ROLE_CLAIM = "https://example.com/roles" _FIXED_PORT = 9000 _DOCS_PATH = "/docs" _USER_SUB = "auth0|x" @@ -248,15 +245,6 @@ def test_browser_reconnect_timeout_is_long(self) -> None: class TestGuiRegisterPages: """Tests for gui_register_pages behaviour.""" - @pytest.fixture(autouse=True) - def _clear_registry(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] - """Ensure the page registry is clean before and after each test.""" - from aignostics_foundry_core.gui import clear_page_registry - - clear_page_registry() - yield - clear_page_registry() - def test_calls_register_pages_on_each_builder(self) -> None: """gui_register_pages calls register_pages() on every discovered builder.""" builder_a = MagicMock(spec=BasePageBuilder) @@ -463,17 +451,6 @@ def test_gui_register_pages_called(self) -> None: class TestGetGuiUser: """Tests for get_gui_user behaviour.""" - @pytest.fixture(autouse=True) - def _gui_context(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] - """Install a minimal context and required AuthSettings env vars.""" - set_context(make_context()) - os.environ[INTERNAL_ORG_ID_VAR_NAME] = _INTERNAL_ORG - os.environ[AUTH0_ROLE_CLAIM_VAR_NAME] = _ROLE_CLAIM - yield - os.environ.pop(INTERNAL_ORG_ID_VAR_NAME, None) - os.environ.pop(AUTH0_ROLE_CLAIM_VAR_NAME, None) - reset_context() - async def test_returns_none_when_auth_client_raises(self) -> None: """Returns None when get_auth_client raises (no auth configured).""" from aignostics_foundry_core.gui.auth import get_gui_user @@ -549,17 +526,6 @@ async def test_returns_none_when_session_has_no_user_key(self) -> None: class TestRequireGuiUser: """Tests for require_gui_user behaviour.""" - @pytest.fixture(autouse=True) - def _gui_context(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] - """Install a minimal context and required AuthSettings env vars.""" - set_context(make_context()) - os.environ[INTERNAL_ORG_ID_VAR_NAME] = _INTERNAL_ORG - os.environ[AUTH0_ROLE_CLAIM_VAR_NAME] = _ROLE_CLAIM - yield - os.environ.pop(INTERNAL_ORG_ID_VAR_NAME, None) - os.environ.pop(AUTH0_ROLE_CLAIM_VAR_NAME, None) - reset_context() - async def test_redirects_to_login_when_no_user(self) -> None: """Redirects to /auth/login when get_gui_user returns None.""" from aignostics_foundry_core.gui.auth import require_gui_user @@ -635,15 +601,6 @@ def _make_nicegui_mock() -> tuple[MagicMock, MagicMock]: class TestPageRegistryDecorators: """Tests for page_* registry decorators (deferred registration).""" - @pytest.fixture(autouse=True) - def _clear_registry(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] - """Ensure registry is clean before and after each test.""" - from aignostics_foundry_core.gui import clear_page_registry - - clear_page_registry() - yield - clear_page_registry() - def _actualize_via_register_pages(self, frame_func: object = None) -> tuple[list[object], MagicMock]: """Run gui_register_pages and return (wrappers, nicegui_mock). @@ -844,6 +801,205 @@ def fake_frame(title: str, **_kw: object): # type: ignore[misc] assert frame_entered == [True] + def test_default_title_uses_context_name(self) -> None: + """When title is omitted, frame_func receives get_context().name.title() at request time.""" + from aignostics_foundry_core.gui.auth import page_public + + titles_received: list[str] = [] + + @contextmanager + def fake_frame(title: str, **_kw: object): # type: ignore[misc] + titles_received.append(title) + yield + + def my_page(user: object) -> None: ... + + page_public(_TEST_PATH)(my_page) + + wrappers, _ = self._actualize_via_register_pages(frame_func=fake_frame) + + assert len(wrappers) == 1 + request = MagicMock() + with patch(_PATCH_GET_GUI_USER, new=AsyncMock(return_value=None)): + asyncio.run(wrappers[0](request)) # type: ignore[arg-type] + + assert titles_received == [TEST_PROJECT_NAME.title()] + + def test_explicit_title_is_passed_unchanged(self) -> None: + """When an explicit title is given, frame_func receives that exact string.""" + from aignostics_foundry_core.gui.auth import page_public + + titles_received: list[str] = [] + + @contextmanager + def fake_frame(title: str, **_kw: object): # type: ignore[misc] + titles_received.append(title) + yield + + def my_page(user: object) -> None: ... + + page_public(_TEST_PATH, title="My Page")(my_page) + + wrappers, _ = self._actualize_via_register_pages(frame_func=fake_frame) + + assert len(wrappers) == 1 + request = MagicMock() + with patch(_PATCH_GET_GUI_USER, new=AsyncMock(return_value=None)): + asyncio.run(wrappers[0](request)) # type: ignore[arg-type] + + assert titles_received == ["My Page"] + + def test_page_admin_renders_forbidden_when_role_is_missing(self) -> None: + """Authenticated user without admin role gets a 403 forbidden label.""" + from aignostics_foundry_core.gui.auth import page_admin + + user = {_ROLE_CLAIM: "other_role"} + fake_auth = MagicMock() + fake_auth.auth0_role_claim = _ROLE_CLAIM + + page_admin(_TEST_PATH)(lambda u: None) # pyright: ignore[reportUnknownLambdaType] + wrappers, nicegui_mock = self._actualize_via_register_pages() + + with ( + patch(_PATCH_REQUIRE_GUI_USER, new=AsyncMock(return_value=user)), + patch(_PATCH_LOAD_SETTINGS, return_value=fake_auth), + ): + asyncio.run(wrappers[0](MagicMock())) # type: ignore[arg-type] + + nicegui_mock.ui.label.assert_called_once_with("403 Forbidden - Admin access required") + + def test_page_admin_invokes_page_func_when_user_is_admin(self) -> None: + """Authenticated user with admin role triggers the page function.""" + from aignostics_foundry_core.gui.auth import page_admin + + page_func_called: list[bool] = [] + + def my_page(user: object) -> None: + page_func_called.append(True) + + user = {_ROLE_CLAIM: "admin"} + fake_auth = MagicMock() + fake_auth.auth0_role_claim = _ROLE_CLAIM + + page_admin(_TEST_PATH)(my_page) + wrappers, _ = self._actualize_via_register_pages() + + with ( + patch(_PATCH_REQUIRE_GUI_USER, new=AsyncMock(return_value=user)), + patch(_PATCH_LOAD_SETTINGS, return_value=fake_auth), + ): + asyncio.run(wrappers[0](MagicMock())) # type: ignore[arg-type] + + assert page_func_called == [True] + + def test_page_internal_renders_forbidden_when_org_id_does_not_match(self) -> None: + """User from a non-internal org gets a 403 forbidden label.""" + from aignostics_foundry_core.gui.auth import page_internal + + user = {"org_id": _OTHER_ORG} + fake_auth = MagicMock() + fake_auth.internal_org_id = _INTERNAL_ORG_ID + + page_internal(_TEST_PATH)(lambda u: None) # pyright: ignore[reportUnknownLambdaType] + wrappers, nicegui_mock = self._actualize_via_register_pages() + + with ( + patch(_PATCH_REQUIRE_GUI_USER, new=AsyncMock(return_value=user)), + patch(_PATCH_LOAD_SETTINGS, return_value=fake_auth), + ): + asyncio.run(wrappers[0](MagicMock())) # type: ignore[arg-type] + + nicegui_mock.ui.label.assert_called_once_with("403 Forbidden - Internal access required") + + def test_page_internal_invokes_page_func_when_user_is_internal(self) -> None: + """User from the internal org triggers the page function.""" + from aignostics_foundry_core.gui.auth import page_internal + + page_func_called: list[bool] = [] + + def my_page(user: object) -> None: + page_func_called.append(True) + + user = {"org_id": _INTERNAL_ORG_ID} + fake_auth = MagicMock() + fake_auth.internal_org_id = _INTERNAL_ORG_ID + + page_internal(_TEST_PATH)(my_page) + wrappers, _ = self._actualize_via_register_pages() + + with ( + patch(_PATCH_REQUIRE_GUI_USER, new=AsyncMock(return_value=user)), + patch(_PATCH_LOAD_SETTINGS, return_value=fake_auth), + ): + asyncio.run(wrappers[0](MagicMock())) # type: ignore[arg-type] + + assert page_func_called == [True] + + def test_page_internal_admin_renders_forbidden_when_only_org_matches(self) -> None: + """User from internal org but without admin role gets a 403 forbidden label.""" + from aignostics_foundry_core.gui.auth import page_internal_admin + + user = {"org_id": _INTERNAL_ORG_ID, _ROLE_CLAIM: "other_role"} + fake_auth = MagicMock() + fake_auth.internal_org_id = _INTERNAL_ORG_ID + fake_auth.auth0_role_claim = _ROLE_CLAIM + + page_internal_admin(_TEST_PATH)(lambda u: None) # pyright: ignore[reportUnknownLambdaType] + wrappers, nicegui_mock = self._actualize_via_register_pages() + + with ( + patch(_PATCH_REQUIRE_GUI_USER, new=AsyncMock(return_value=user)), + patch(_PATCH_LOAD_SETTINGS, return_value=fake_auth), + ): + asyncio.run(wrappers[0](MagicMock())) # type: ignore[arg-type] + + nicegui_mock.ui.label.assert_called_once_with("403 Forbidden - Internal admin access required") + + def test_page_internal_admin_renders_forbidden_when_only_role_matches(self) -> None: + """Admin-role user from a non-internal org gets a 403 forbidden label.""" + from aignostics_foundry_core.gui.auth import page_internal_admin + + user = {"org_id": _OTHER_ORG, _ROLE_CLAIM: "admin"} + fake_auth = MagicMock() + fake_auth.internal_org_id = _INTERNAL_ORG_ID + fake_auth.auth0_role_claim = _ROLE_CLAIM + + page_internal_admin(_TEST_PATH)(lambda u: None) # pyright: ignore[reportUnknownLambdaType] + wrappers, nicegui_mock = self._actualize_via_register_pages() + + with ( + patch(_PATCH_REQUIRE_GUI_USER, new=AsyncMock(return_value=user)), + patch(_PATCH_LOAD_SETTINGS, return_value=fake_auth), + ): + asyncio.run(wrappers[0](MagicMock())) # type: ignore[arg-type] + + nicegui_mock.ui.label.assert_called_once_with("403 Forbidden - Internal admin access required") + + def test_page_internal_admin_invokes_page_func_when_user_is_internal_admin(self) -> None: + """User from internal org with admin role triggers the page function.""" + from aignostics_foundry_core.gui.auth import page_internal_admin + + page_func_called: list[bool] = [] + + def my_page(user: object) -> None: + page_func_called.append(True) + + user = {"org_id": _INTERNAL_ORG_ID, _ROLE_CLAIM: "admin"} + fake_auth = MagicMock() + fake_auth.internal_org_id = _INTERNAL_ORG_ID + fake_auth.auth0_role_claim = _ROLE_CLAIM + + page_internal_admin(_TEST_PATH)(my_page) + wrappers, _ = self._actualize_via_register_pages() + + with ( + patch(_PATCH_REQUIRE_GUI_USER, new=AsyncMock(return_value=user)), + patch(_PATCH_LOAD_SETTINGS, return_value=fake_auth), + ): + asyncio.run(wrappers[0](MagicMock())) # type: ignore[arg-type] + + assert page_func_called == [True] + # --------------------------------------------------------------------------- # GUINamespace @@ -930,3 +1086,31 @@ def test_gui_run_frame_func_parameter_accepted(self) -> None: patch(_PATH_CORE_LOCATE, return_value=[]), ): gui_run(context=make_context(), frame_func=fake_frame) # must not raise + + def test_gui_namespace_default_title_uses_context_name(self) -> None: + """GUINamespace.public with no title uses get_context().name.title() at request time.""" + from aignostics_foundry_core.gui.auth import GUINamespace + + titles_received: list[str] = [] + + @contextmanager + def fake_frame(title: str, **_kw: object): # type: ignore[misc] + titles_received.append(title) + yield + + namespace = GUINamespace(frame_func=fake_frame) + wrappers: list[object] = [] + nicegui_mock = MagicMock() + nicegui_mock.ui.page.side_effect = ( # pyright: ignore[reportUnknownMemberType] + lambda *a, **kw: lambda f: wrappers.append(f) or f # pyright: ignore[reportUnknownLambdaType, reportUnknownArgumentType] + ) + + with patch.dict(sys.modules, {"nicegui": nicegui_mock}): + namespace.public(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] + + assert len(wrappers) == 1 + request = MagicMock() + with patch(_PATCH_GET_GUI_USER, new=AsyncMock(return_value=None)): + asyncio.run(wrappers[0](request)) # type: ignore[arg-type] + + assert titles_received == [TEST_PROJECT_NAME.title()] diff --git a/tests/aignostics_foundry_core/log_test.py b/tests/aignostics_foundry_core/log_test.py index 0e429b0..83c90d1 100644 --- a/tests/aignostics_foundry_core/log_test.py +++ b/tests/aignostics_foundry_core/log_test.py @@ -8,7 +8,7 @@ from pydantic import ValidationError from aignostics_foundry_core.log import InterceptHandler, LogSettings, logging_initialize -from tests.conftest import TEST_PROJECT_PREFIX, make_context +from tests.conftest import TEST_PROJECT_PREFIX _MARKER_MESSAGE = "log_test_unique_marker_4f2a" _STDLIB_MESSAGE = "stdlib_redirect_unique_marker_9b3c" @@ -23,13 +23,6 @@ class TestLoggingInitialize: """Behavioural tests for logging_initialize().""" - @pytest.fixture(autouse=True) - def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "aignostics_foundry_core.log.get_context", - make_context, - ) - def test_logging_initialize_adds_stderr_handler(self, capsys: pytest.CaptureFixture[str]) -> None: """After initialization with defaults, a log message appears on stderr.""" logging_initialize() @@ -117,10 +110,6 @@ def test_logging_initialize_respects_env_prefix_from_context( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """LogSettings reads env vars from the prefix of the active get_context().""" - monkeypatch.setattr( - "aignostics_foundry_core.log.get_context", - make_context, - ) monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}LOG_STDERR_ENABLED", "false") logging_initialize() from loguru import logger @@ -133,20 +122,9 @@ def test_logging_initialize_respects_env_prefix_from_context( class TestLogSettings: """Behavioural tests for LogSettings validation.""" - @pytest.fixture(autouse=True) - def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "aignostics_foundry_core.log.get_context", - make_context, - ) - @pytest.mark.unit def test_log_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: """LogSettings reads env vars using the env_prefix from the active FoundryContext.""" - monkeypatch.setattr( - "aignostics_foundry_core.log.get_context", - make_context, - ) monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}LOG_STDERR_ENABLED", "false") settings = LogSettings() # pyright: ignore[reportCallIssue] assert settings.stderr_enabled is False diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index 3cc103a..899c02b 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -1,12 +1,11 @@ """Tests for aignostics_foundry_core.sentry.""" -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest from pydantic import ValidationError -from aignostics_foundry_core.foundry import reset_context, set_context +from aignostics_foundry_core.foundry import set_context from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user from tests.conftest import TEST_PROJECT_NAME, TEST_PROJECT_PREFIX, make_context @@ -23,12 +22,6 @@ class TestSentryInitialize: """Behavioural tests for sentry_initialize().""" - @pytest.fixture(autouse=True) - def _context(self) -> Generator[None, None, None]: - set_context(make_context()) - yield - reset_context() - def test_sentry_initialize_returns_false_when_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: """Returns False when TESTPROJECT_SENTRY_ENABLED is not set (default False).""" monkeypatch.delenv(f"{_SENTRY_PREFIX}ENABLED", raising=False) @@ -110,12 +103,6 @@ def test_sentry_initialize_uses_sentry_context_flags(self, monkeypatch: pytest.M class TestSentrySettingsDsnValidation: """Tests for SentrySettings DSN edge-case validation paths.""" - @pytest.fixture(autouse=True) - def _context(self) -> Generator[None, None, None]: - set_context(make_context()) - yield - reset_context() - def test_dsn_missing_scheme_raises(self) -> None: """DSN without a URL scheme raises ValidationError.""" with pytest.raises(ValidationError): @@ -136,12 +123,6 @@ def test_dsn_missing_at_sign_raises(self) -> None: class TestSentrySettings: """Behavioural tests for SentrySettings validation.""" - @pytest.fixture(autouse=True) - def _context(self) -> Generator[None, None, None]: - set_context(make_context()) - yield - reset_context() - def test_sentry_settings_rejects_invalid_dsn_http_scheme(self) -> None: """DSN with http:// scheme raises ValidationError.""" with pytest.raises(ValidationError): diff --git a/tests/conftest.py b/tests/conftest.py index 60232d3..232dd0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,13 +2,14 @@ import logging import os +from collections.abc import Generator from pathlib import Path import psutil import pytest from aignostics_foundry_core.database import DatabaseSettings -from aignostics_foundry_core.foundry import FoundryContext, PackageMetadata +from aignostics_foundry_core.foundry import FoundryContext, PackageMetadata, reset_context, set_context __all__ = ["make_context"] @@ -61,6 +62,18 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: session.exitstatus = 0 +@pytest.fixture(autouse=True) +def _set_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] + """Reset the global context before and after every test for isolation. + + Yields: + None + """ + set_context(make_context()) + yield + reset_context() + + def make_context( # noqa: PLR0913 name: str = TEST_PROJECT_NAME, *,