Ctrl K
ComunidadGuías…

backend/tests/test_base_url_prefix.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Serving Sublarr under a reverse-proxy path prefix.

The settings page has offered a "Base URL" field for a long time. It stored the
value and nothing ever read it: no route, no template, no link builder. Setting
it did nothing at all.

Wiring it follows the convention of the *arr applications this sits next to —
the application answers under the prefix itself, so the proxy can forward the
path unchanged (``proxy_pass http://host:5765;`` with no trailing slash). The
un-prefixed paths keep working too, deliberately: a wrong value in that field
would otherwise lock the user out of the UI that fixes it.

Two things have to line up for the browser:

* requests to ``/<prefix>/api/v1/...`` must reach the same routes as
  ``/api/v1/...``;
* the served ``index.html`` must carry ``<base href="/<prefix>/">`` so the
  relative asset URLs in the bundle resolve there and not against whatever deep
  client-side route the user reloaded on.
"""

from __future__ import annotations

import pytest

from db.config import save_config_entry


def _set_base_url(client, value: str) -> None:
    """Store the value and reload settings — exactly what PUT /config does.

    The prefix is read off the settings singleton because the middleware runs
    before Flask has an application context, so a test that only writes the
    config entry would not see the prefix take effect and would prove nothing.
    """
    from config import reload_settings
    from db.config import get_all_config_entries

    with client.application.app_context():
        save_config_entry("base_url", value)
        reload_settings(get_all_config_entries())


@pytest.fixture
def prefixed(client):
    """Set a base URL the way the settings page does."""
    _set_base_url(client, "/sublarr")
    return client


def test_api_answers_under_the_prefix(prefixed):
    resp = prefixed.get("/sublarr/api/v1/health")
    assert resp.status_code == 200, resp.get_data(as_text=True)[:200]
    assert "status" in resp.get_json(), resp.get_json()


def test_api_still_answers_without_the_prefix(prefixed):
    """A wrong Base URL must not lock the user out of fixing it."""
    resp = prefixed.get("/api/v1/health")
    assert resp.status_code == 200
    assert "status" in resp.get_json(), resp.get_json()


def test_unset_base_url_changes_nothing(client):
    _set_base_url(client, "")
    resp = client.get("/api/v1/health")
    assert resp.status_code == 200
    assert "status" in resp.get_json(), resp.get_json()


def test_a_prefix_that_only_looks_like_one_is_not_stripped(prefixed):
    """``/sublarrX`` shares a prefix string but is a different location.

    It must not reach the API. What it gets instead is the SPA catch-all, the
    same as any other unknown path — so the check is "did not reach health",
    not a status code.
    """
    resp = prefixed.get("/sublarrX/api/v1/health")
    assert "status" not in (resp.get_json() or {}), resp.get_json()


@pytest.mark.parametrize("stored", ["/sublarr", "sublarr", "/sublarr/", "  /sublarr//  "])
def test_the_shapes_a_user_types_all_work(client, stored):
    _set_base_url(client, stored)
    body = client.get("/sublarr/api/v1/health").get_json()
    assert "status" in (body or {}), body


def test_index_html_carries_the_base_element(prefixed, tmp_path, monkeypatch):
    from flask import current_app

    static = tmp_path / "static"
    static.mkdir()
    (static / "index.html").write_text(
        '<!DOCTYPE html><html><head><base href="/" />'
        '<script type="module" src="./assets/app.js"></script></head>'
        "<body><div id=root></div></body></html>",
        encoding="utf-8",
    )
    with prefixed.application.app_context():
        current_app.static_folder = str(static)

    body = prefixed.get("/sublarr/").get_data(as_text=True)

    assert '<base href="/sublarr/"' in body, body[:300]
    # The prefix reaches the app through <base> alone: an inline script is
    # blocked by the strict script-src CSP, so it never ran (1.14.0-1.15.0).
    assert "<script>" not in body, body[:300]


def test_index_html_at_the_root_keeps_the_plain_base(client, tmp_path):
    from flask import current_app

    static = tmp_path / "static"
    static.mkdir()
    (static / "index.html").write_text(
        '<!DOCTYPE html><html><head><base href="/" /></head><body></body></html>',
        encoding="utf-8",
    )
    with client.application.app_context():
        current_app.static_folder = str(static)

    body = client.get("/").get_data(as_text=True)

    assert '<base href="/"' in body
    assert "<script>" not in body


class TestHostileBaseUrl:
    """The prefix reaches an HTML attribute and a JS string literal.

    It is written by an authenticated caller, but it is *stored* and then
    rendered for everyone who opens the page — and `/config/import` will take
    it from a backup file whose origin nobody checked. Neither sink may be
    escapable.
    """

    @pytest.mark.parametrize(
        "hostile",
        [
            '/x" onload="alert(1)',
            "/x'><script>alert(1)</script>",
            '/x";alert(1);//',
            "/x</script><script>alert(1)</script>",
            "/x\\",
            "javascript:alert(1)",
            "//evil.example.com",
        ],
    )
    def test_a_hostile_value_cannot_escape_either_sink(self, hostile):
        from base_path import inject_base_into_index, normalize_base_path

        shell = '<html><head><base href="/" /></head><body></body></html>'
        out = inject_base_into_index(shell, normalize_base_path(hostile))

        assert "<script>alert(1)</script>" not in out, out
        assert "onload=" not in out, out
        # exactly one base element survives, and no script at all
        assert out.count("<base ") == 1, out
        assert "<script>" not in out, out

    @pytest.mark.parametrize(
        "bad",
        ['/x" onload="y', "/x<script>", "javascript:alert(1)", "//evil.example.com", "/a b"],
    )
    def test_values_that_are_not_a_path_prefix_are_refused(self, bad):
        from base_path import normalize_base_path

        assert normalize_base_path(bad) == "", f"{bad!r} was accepted as a prefix"

    @pytest.mark.parametrize("good", ["/sublarr", "sublarr", "/media/subs", "/a_b-c.d~e"])
    def test_real_prefixes_still_pass(self, good):
        from base_path import normalize_base_path

        assert normalize_base_path(good).startswith("/")


class TestMiddlewareNeverBuildsSettings:
    """The prefix lookup must never be the thing that creates the settings.

    The middleware runs before Flask pushes an application context, and
    ``get_settings()`` builds the singleton by merging the database overrides
    on top of the defaults — a read it cannot do without that context. Calling
    it from here therefore caches a Settings object with **every stored setting
    missing**, and every later reader gets that object: measured, an override of
    ``wanted_anime_only=False`` came back ``True`` and stayed ``True``.

    So the rule is not "handle the exception" but "never trigger the build".
    An unbuilt singleton means serve at the root for that one request, which is
    the safe default and corrects itself as soon as the app has started.
    """

    def test_get_base_path_does_not_create_the_singleton(self):
        import config_singleton
        from base_path import get_base_path

        config_singleton._settings = None
        try:
            assert get_base_path() == ""
            assert config_singleton._settings is None, (
                "the prefix lookup built the settings singleton — it would be "
                "built without the database overrides"
            )
        finally:
            config_singleton._settings = None

    def test_stored_overrides_survive_a_request(self, client):
        """The end the rule protects: a DB override must not be lost."""
        import config_singleton
        from config import get_settings, reload_settings
        from db.config import get_all_config_entries, save_config_entry

        with client.application.app_context():
            save_config_entry("wanted_anime_only", "false")
            reload_settings(get_all_config_entries())

        config_singleton._settings = None
        client.get("/api/v1/health")

        with client.application.app_context():
            reload_settings(get_all_config_entries())
            assert get_settings().wanted_anime_only is False


def _is_socketio_handshake(body: str) -> bool:
    """Engine.IO opens a polling session with packet type 0 and a JSON payload."""
    return body.startswith('0{"sid"')


def test_socketio_handshake_reaches_socketio_under_the_prefix(prefixed):
    """Socket.IO must see its own path, not the SPA catch-all.

    ``socketio.init_app`` wraps ``app.wsgi_app`` in its own middleware, so
    whichever layer is applied last ends up outermost. With the prefix stripper
    applied first it sat *inside*: Socket.IO saw ``/sublarr/socket.io/...``, did
    not recognise it and handed it on, the prefix was stripped afterwards, Flask
    had no such route, and the caller got the SPA catch-all with status 200. A
    websocket client cannot tell that apart from a server that is not there.

    Asserting positively on the handshake matters. The first version of this
    test checked that the body was not HTML and passed — in the test
    environment the catch-all answers with JSON, so it never looked at the
    thing it claimed to check. The bug was found on the beta container.
    """
    body = prefixed.get("/sublarr/socket.io/?EIO=4&transport=polling").get_data(as_text=True)
    assert _is_socketio_handshake(body), f"not a handshake: {body[:120]!r}"


def test_socketio_handshake_still_works_without_the_prefix(prefixed):
    body = prefixed.get("/socket.io/?EIO=4&transport=polling").get_data(as_text=True)
    assert _is_socketio_handshake(body), f"not a handshake: {body[:120]!r}"