diff --git a/tests/proxy_migration_tests/test_ui_image_serves_offline.py b/tests/proxy_migration_tests/test_ui_image_serves_offline.py index 5ff68effd7f..cd5dda827e6 100644 --- a/tests/proxy_migration_tests/test_ui_image_serves_offline.py +++ b/tests/proxy_migration_tests/test_ui_image_serves_offline.py @@ -1,4 +1,4 @@ -"""Image-level regression net for arbitrary-uid boot of the UI image. +"""Image-level regression net for the standalone UI image. OpenShift ``restricted-v2`` ignores the image ``USER`` and assigns an arbitrary uid in GID 0. The stock nginx base expects to start as root, so @@ -12,6 +12,11 @@ filesystem and ``/tmp`` as the only writable mount, is what catches the whole class: a boot as the default (root) uid passes even on the broken config. +The Next.js client requests RSC payloads under the dashboard's ``/ui`` URL +prefix, while the static export stores them at the nginx document root. The +image must strip that prefix for existing payloads without serving HTML for a +missing payload. + Gated on LITELLM_IMAGE so it is skipped in the normal unit-test run and exercised only where an image has been built (the image-scan workflow). Requires a working docker CLI. @@ -65,6 +70,7 @@ def ui_container() -> Iterator[tuple[str, str]]: "--read-only", "--tmpfs", "/tmp", IMAGE, ) + _wait_for_ui(network, container) yield network, container finally: _docker("logs", container, check=False) @@ -96,17 +102,30 @@ def _probe(network: str, container: str, path: str) -> "subprocess.CompletedProc ) -def test_ui_serves_as_arbitrary_uid_read_only(ui_container: tuple[str, str]) -> None: - """nginx boots and serves as an arbitrary uid with a read-only root fs. +def _fetch_body(network: str, container: str, path: str) -> "subprocess.CompletedProcess[str]": + """Fetch a payload body and make curl fail on an HTTP error response.""" + return _docker( + "run", "--rm", "--network", network, CURL_IMAGE, + "--silent", "--show-error", "--fail", "--max-time", "10", + f"http://{container}:{UI_PORT}{path}", + check=False, + ) - On the pre-fix config nginx exits during startup with - ``mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)`` - and the running-check below fails; it never reaches the probes. - """ - network, container = ui_container +def _probe_content_type(network: str, container: str, path: str) -> "subprocess.CompletedProcess[str]": + """Read the response MIME type without retaining another payload body.""" + return _docker( + "run", "--rm", "--network", network, CURL_IMAGE, + "--silent", "--show-error", "--fail", "--max-time", "10", + "--output", "/dev/null", "--write-out", "%{content_type}", + f"http://{container}:{UI_PORT}{path}", + check=False, + ) + + +def _wait_for_ui(network: str, container: str) -> None: + """Wait for nginx to serve traffic or fail with its startup logs.""" deadline = time.time() + STARTUP_TIMEOUT_SECONDS - healthz = None while time.time() < deadline: if not _is_running(container): pytest.fail( @@ -116,17 +135,63 @@ def test_ui_serves_as_arbitrary_uid_read_only(ui_container: tuple[str, str]) -> ) healthz = _probe(network, container, "/healthz") if healthz.returncode == 0 and healthz.stdout.strip() == "200": - break + return time.sleep(2) - assert healthz is not None and healthz.stdout.strip() == "200", ( + pytest.fail( f"/healthz never answered 200 within {STARTUP_TIMEOUT_SECONDS}s as uid " f"{ARBITRARY_UID}.\n{_container_logs(container)}" ) + +def test_ui_serves_as_arbitrary_uid_read_only(ui_container: tuple[str, str]) -> None: + """nginx boots and serves as an arbitrary uid with a read-only root fs. + + On the pre-fix config nginx exits during startup with + ``mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)`` + and the fixture fails before it reaches these route probes. + """ + network, container = ui_container + for path in ("/", "/ui", "/ui/login"): page = _probe(network, container, path) assert page.stdout.strip() == "200", ( f"GET {path} returned {page.stdout.strip()!r} as uid {ARBITRARY_UID}.\n" f"{_container_logs(container)}" ) + + +@pytest.mark.parametrize( + ("canonical_path", "ui_path"), + ( + ("/index.txt", "/ui/index.txt"), + ("/api-keys/index.txt", "/ui/api-keys/index.txt"), + ("/api-keys/index.txt", "/ui/api-keys.txt"), + ("/__next._tree.txt", "/ui/__next._tree.txt"), + ("/api-keys/__next._tree.txt", "/ui/api-keys/__next._tree.txt"), + ), + ids=("dashboard-root", "trailing-slash-route", "route", "root-tree", "nested-tree"), +) +def test_ui_prefixed_rsc_payload_matches_static_export( + ui_container: tuple[str, str], canonical_path: str, ui_path: str, +) -> None: + network, container = ui_container + + canonical_payload = _fetch_body(network, container, canonical_path) + ui_payload = _fetch_body(network, container, f"{ui_path}?_rsc=regression") + ui_content_type = _probe_content_type(network, container, f"{ui_path}?_rsc=regression") + + assert canonical_payload.returncode == 0, canonical_payload.stderr + assert ui_payload.returncode == 0, ui_payload.stderr + assert ui_content_type.returncode == 0, ui_content_type.stderr + assert canonical_payload.stdout + assert ui_payload.stdout == canonical_payload.stdout + assert ui_content_type.stdout.strip().startswith("text/plain") + + +def test_ui_prefixed_missing_rsc_payload_stays_404(ui_container: tuple[str, str]) -> None: + network, container = ui_container + + missing_payload = _probe(network, container, "/ui/does-not-exist.txt?_rsc=regression") + + assert missing_payload.stdout.strip() == "404", _container_logs(container) diff --git a/ui/nginx.conf b/ui/nginx.conf index 235cb9c501e..3db562c7c65 100644 --- a/ui/nginx.conf +++ b/ui/nginx.conf @@ -85,6 +85,10 @@ http { # /ui/.txt would otherwise be rewritten to HTML and break RSC # for nested routes. A genuinely missing payload must 404 (the # router degrades to a hard navigation); never fall back to HTML. + location ~ ^/ui/(.+)\.txt$ { + try_files /$1.txt /$1/index.txt =404; + } + location ~ \.txt$ { try_files $uri =404; }